Merge pull request #4 from lnbits/zap_support

Zap support
This commit is contained in:
callebtc 2023-05-30 17:31:22 +02:00 committed by GitHub
commit 6bce388dcd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 729 additions and 128 deletions

View file

@ -24,6 +24,7 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
2. Use the shareable link or view the LNURLp you just created\ 2. Use the shareable link or view the LNURLp you just created\
![LNURLp](https://i.imgur.com/C8s1P0Q.jpg) ![LNURLp](https://i.imgur.com/C8s1P0Q.jpg)
- you can now open your LNURLp and copy the LNURL, get the shareable link or print it\ - you can now open your LNURLp and copy the LNURL, get the shareable link or print it\
![view lnurlp](https://i.imgur.com/4n41S7T.jpg) ![view lnurlp](https://i.imgur.com/4n41S7T.jpg)

View file

@ -7,6 +7,28 @@ from fastapi.staticfiles import StaticFiles
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart from lnbits.tasks import catch_everything_and_restart
from loguru import logger
from .nostr.event import Event
from .nostr.key import PrivateKey, PublicKey
from environs import Env
def generate_keys(private_key: str = ""):
if private_key.startswith("nsec"):
return PrivateKey.from_nsec(private_key)
elif private_key:
return PrivateKey(bytes.fromhex(private_key))
else:
return PrivateKey() # generate random key
env = Env()
env.read_env()
nostr_privatekey = generate_keys(env.str("LNURLP_ZAP_NOSTR_PRIVATEKEY", default=""))
nostr_publickey: PublicKey = nostr_privatekey.public_key
logger.debug(f"LNURLP Zaps Nostr pubkey: {nostr_publickey.hex()}")
db = Database("ext_lnurlp") db = Database("ext_lnurlp")
@ -19,10 +41,10 @@ lnurlp_static_files = [
] ]
lnurlp_redirect_paths = [ lnurlp_redirect_paths = [
{ {
"from_path": "/.well-known/lnurlp", "from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known", "redirect_to_path": "/api/v1/well-known",
} }
] ]
scheduled_tasks: List[asyncio.Task] = [] scheduled_tasks: List[asyncio.Task] = []

View file

@ -1,10 +1,6 @@
{ {
"name": "LNURLp", "name": "LNURLp",
"short_description": "Make reusable LNURL pay links", "short_description": "Make reusable LNURL pay links",
"tile": "/lnurlp/static/image/lnurl-pay.png", "tile": "/lnurlp/static/image/lnurl-pay.png",
"contributors": [ "contributors": ["arcbtc", "eillarra", "fiatjaf", "callebtc"]
"arcbtc",
"eillarra",
"fiatjaf"
]
} }

22
crud.py
View file

@ -5,6 +5,7 @@ from lnbits.helpers import urlsafe_short_hash
from . import db # , maindb from . import db # , maindb
from .models import CreatePayLinkData, PayLink from .models import CreatePayLinkData, PayLink
from .services import check_lnaddress_format
# from loguru import logger # from loguru import logger
@ -15,9 +16,8 @@ async def check_lnaddress_update(username: str, id: str) -> bool:
"SELECT username FROM lnurlp.pay_links WHERE username = ? AND id = ?", "SELECT username FROM lnurlp.pay_links WHERE username = ? AND id = ?",
(username, id), (username, id),
) )
if len(row) > 1: if row:
assert False, "Username already exists. Try a different one." raise Exception("Username already exists. Try a different one.")
return
else: else:
return True return True
@ -28,19 +28,11 @@ async def check_lnaddress_not_exists(username: str) -> bool:
"SELECT username FROM lnurlp.pay_links WHERE username = ?", (username,) "SELECT username FROM lnurlp.pay_links WHERE username = ?", (username,)
) )
if row: if row:
assert False, "Username already exists. Try a different one." raise Exception("Username already exists. Try a different one.")
else: else:
return True return True
async def check_lnaddress_format(username: str) -> bool:
# check username complies with lnaddress specification
if not re.match("^[a-z0-9-_.]{3,15}$", username):
assert False, "Only letters a-z0-9-_. allowed, min 3 and max 15 characters!"
return
return True
async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink: async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
if data.username: if data.username:
await check_lnaddress_format(data.username) await check_lnaddress_format(data.username)
@ -66,10 +58,11 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
comment_chars, comment_chars,
currency, currency,
fiat_base_multiplier, fiat_base_multiplier,
username username,
zaps
) )
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
link_id, link_id,
@ -86,6 +79,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
data.currency, data.currency,
data.fiat_base_multiplier, data.fiat_base_multiplier,
data.username, data.username,
data.zaps,
), ),
) )
assert result assert result

View file

@ -11,6 +11,8 @@ from . import lnurlp_ext
from .crud import increment_pay_link, get_pay_link, get_address_data from .crud import increment_pay_link, get_pay_link, get_address_data
from loguru import logger from loguru import logger
from urllib.parse import urlparse from urllib.parse import urlparse
import json
from . import nostr_publickey
@lnurlp_ext.get( @lnurlp_ext.get(
@ -47,15 +49,15 @@ async def api_lnurl_callback(
min = link.min * 1000 min = link.min * 1000
max = link.max * 1000 max = link.max * 1000
amount_received = amount amount = amount
if amount_received < min: if amount < min:
return LnurlErrorResponse( return LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}." reason=f"Amount {amount} is smaller than minimum {min}."
).dict() ).dict()
elif amount_received > max: elif amount > max:
return LnurlErrorResponse( return LnurlErrorResponse(
reason=f"Amount {amount_received} is greater than maximum {max}." reason=f"Amount {amount} is greater than maximum {max}."
).dict() ).dict()
comment = request.query_params.get("comment") comment = request.query_params.get("comment")
@ -77,14 +79,21 @@ async def api_lnurl_callback(
if comment: if comment:
extra["comment"] = (comment,) extra["comment"] = (comment,)
# nip 57
nostr = request.query_params.get("nostr")
if nostr:
extra["nostr"] = nostr # put it here for later publishing in tasks.py
if lnaddress and link.username and link.domain: if lnaddress and link.username and link.domain:
extra["lnaddress"] = f"{link.username}@{link.domain}" extra["lnaddress"] = f"{link.username}@{link.domain}"
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=link.wallet, wallet_id=link.wallet,
amount=int(amount_received / 1000), amount=int(amount / 1000),
memo=link.description, memo=link.description,
unhashed_description=link.lnurlpay_metadata.encode(), unhashed_description=nostr.encode()
if nostr # we take the zap request as the description instead of the LNURL metadata if present
else link.lnurlpay_metadata.encode(),
extra=extra, extra=extra,
) )
@ -136,4 +145,7 @@ async def api_lnurl_response(request: Request, link_id, lnaddress=False):
if link.comment_chars > 0: if link.comment_chars > 0:
params["commentAllowed"] = link.comment_chars params["commentAllowed"] = link.comment_chars
if link.zaps:
params["allowsNostr"] = True
params["nostrPubkey"] = nostr_publickey.hex()
return params return params

View file

@ -1,10 +1,9 @@
{ {
"repos": [ "repos": [
{ {
"id": "lnurlp", "id": "lnurlp",
"organisation": "lnbits", "organisation": "lnbits",
"repository": "lnurlp" "repository": "lnurlp"
} }
] ]
} }

View file

@ -150,6 +150,13 @@ async def m006_redux(db):
async def m007_add_lnaddress_username(db): async def m007_add_lnaddress_username(db):
""" """
Add headers and body to webhooks Add Lightning address to pay links
""" """
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN username TEXT;") await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN username TEXT;")
async def m008_add_zap_enabled_column(db):
"""
Add Nostr zaps to pay links
"""
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN zaps BOOLEAN;")

View file

@ -24,6 +24,7 @@ class CreatePayLinkData(BaseModel):
success_url: str = Query(None) success_url: str = Query(None)
fiat_base_multiplier: int = Query(100, ge=1) fiat_base_multiplier: int = Query(100, ge=1)
username: str = Query(None) username: str = Query(None)
zaps: bool = Query(False)
class PayLink(BaseModel): class PayLink(BaseModel):
@ -34,6 +35,7 @@ class PayLink(BaseModel):
served_meta: int served_meta: int
served_pr: int served_pr: int
username: Optional[str] username: Optional[str]
zaps: Optional[bool]
domain: Optional[str] domain: Optional[str]
webhook_url: Optional[str] webhook_url: Optional[str]
webhook_headers: Optional[str] webhook_headers: Optional[str]

137
nostr/bech32.py Normal file
View file

@ -0,0 +1,137 @@
# Copyright (c) 2017, 2020 Pieter Wuille
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""Reference implementation for Bech32/Bech32m and segwit addresses."""
from enum import Enum
class Encoding(Enum):
"""Enumeration type to list the various supported encodings."""
BECH32 = 1
BECH32M = 2
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
BECH32M_CONST = 0x2bc830a3
def bech32_polymod(values):
"""Internal function that computes the Bech32 checksum."""
generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
chk = 1
for value in values:
top = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ value
for i in range(5):
chk ^= generator[i] if ((top >> i) & 1) else 0
return chk
def bech32_hrp_expand(hrp):
"""Expand the HRP into values for checksum computation."""
return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]
def bech32_verify_checksum(hrp, data):
"""Verify a checksum given HRP and converted data characters."""
const = bech32_polymod(bech32_hrp_expand(hrp) + data)
if const == 1:
return Encoding.BECH32
if const == BECH32M_CONST:
return Encoding.BECH32M
return None
def bech32_create_checksum(hrp, data, spec):
"""Compute the checksum values given HRP and data."""
values = bech32_hrp_expand(hrp) + data
const = BECH32M_CONST if spec == Encoding.BECH32M else 1
polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const
return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
def bech32_encode(hrp, data, spec):
"""Compute a Bech32 string given HRP and data values."""
combined = data + bech32_create_checksum(hrp, data, spec)
return hrp + '1' + ''.join([CHARSET[d] for d in combined])
def bech32_decode(bech):
"""Validate a Bech32/Bech32m string, and determine HRP and data."""
if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or
(bech.lower() != bech and bech.upper() != bech)):
return (None, None, None)
bech = bech.lower()
pos = bech.rfind('1')
if pos < 1 or pos + 7 > len(bech) or len(bech) > 90:
return (None, None, None)
if not all(x in CHARSET for x in bech[pos+1:]):
return (None, None, None)
hrp = bech[:pos]
data = [CHARSET.find(x) for x in bech[pos+1:]]
spec = bech32_verify_checksum(hrp, data)
if spec is None:
return (None, None, None)
return (hrp, data[:-6], spec)
def convertbits(data, frombits, tobits, pad=True):
"""General power-of-2 base conversion."""
acc = 0
bits = 0
ret = []
maxv = (1 << tobits) - 1
max_acc = (1 << (frombits + tobits - 1)) - 1
for value in data:
if value < 0 or (value >> frombits):
return None
acc = ((acc << frombits) | value) & max_acc
bits += frombits
while bits >= tobits:
bits -= tobits
ret.append((acc >> bits) & maxv)
if pad:
if bits:
ret.append((acc << (tobits - bits)) & maxv)
elif bits >= frombits or ((acc << (tobits - bits)) & maxv):
return None
return ret
def decode(hrp, addr):
"""Decode a segwit address."""
hrpgot, data, spec = bech32_decode(addr)
if hrpgot != hrp:
return (None, None)
decoded = convertbits(data[1:], 5, 8, False)
if decoded is None or len(decoded) < 2 or len(decoded) > 40:
return (None, None)
if data[0] > 16:
return (None, None)
if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32:
return (None, None)
if data[0] == 0 and spec != Encoding.BECH32 or data[0] != 0 and spec != Encoding.BECH32M:
return (None, None)
return (data[0], decoded)
def encode(hrp, witver, witprog):
"""Encode a segwit address."""
spec = Encoding.BECH32 if witver == 0 else Encoding.BECH32M
ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5), spec)
if decode(hrp, ret) == (None, None):
return None
return ret

126
nostr/event.py Normal file
View file

@ -0,0 +1,126 @@
import time
import json
from dataclasses import dataclass, field
from enum import IntEnum
from typing import List
from secp256k1 import PublicKey
from hashlib import sha256
from .message_type import ClientMessageType
class EventKind(IntEnum):
SET_METADATA = 0
TEXT_NOTE = 1
RECOMMEND_RELAY = 2
CONTACTS = 3
ENCRYPTED_DIRECT_MESSAGE = 4
DELETE = 5
@dataclass
class Event:
content: str = None
public_key: str = None
created_at: int = None
kind: int = EventKind.TEXT_NOTE
tags: List[List[str]] = field(
default_factory=list
) # Dataclasses require special handling when the default value is a mutable type
signature: str = None
def __post_init__(self):
if self.content is not None and not isinstance(self.content, str):
# DMs initialize content to None but all other kinds should pass in a str
raise TypeError("Argument 'content' must be of type str")
if self.created_at is None:
self.created_at = int(time.time())
@staticmethod
def serialize(
public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str
) -> bytes:
data = [0, public_key, created_at, kind, tags, content]
data_str = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
return data_str.encode()
@staticmethod
def compute_id(
public_key: str, created_at: int, kind: int, tags: List[List[str]], content: str
):
return sha256(
Event.serialize(public_key, created_at, kind, tags, content)
).hexdigest()
@property
def id(self) -> str:
# Always recompute the id to reflect the up-to-date state of the Event
return Event.compute_id(
self.public_key, self.created_at, self.kind, self.tags, self.content
)
def add_pubkey_ref(self, pubkey: str):
"""Adds a reference to a pubkey as a 'p' tag"""
self.tags.append(["p", pubkey])
def add_event_ref(self, event_id: str):
"""Adds a reference to an event_id as an 'e' tag"""
self.tags.append(["e", event_id])
def verify(self) -> bool:
pub_key = PublicKey(
bytes.fromhex("02" + self.public_key), True
) # add 02 for schnorr (bip340)
return pub_key.schnorr_verify(
bytes.fromhex(self.id), bytes.fromhex(self.signature), None, raw=True
)
def to_message(self) -> str:
return json.dumps(
[
ClientMessageType.EVENT,
{
"id": self.id,
"pubkey": self.public_key,
"created_at": self.created_at,
"kind": self.kind,
"tags": self.tags,
"content": self.content,
"sig": self.signature,
},
]
)
@dataclass
class EncryptedDirectMessage(Event):
recipient_pubkey: str = None
cleartext_content: str = None
reference_event_id: str = None
def __post_init__(self):
if self.content is not None:
self.cleartext_content = self.content
self.content = None
if self.recipient_pubkey is None:
raise Exception("Must specify a recipient_pubkey.")
self.kind = EventKind.ENCRYPTED_DIRECT_MESSAGE
super().__post_init__()
# Must specify the DM recipient's pubkey in a 'p' tag
self.add_pubkey_ref(self.recipient_pubkey)
# Optionally specify a reference event (DM) this is a reply to
if self.reference_event_id is not None:
self.add_event_ref(self.reference_event_id)
@property
def id(self) -> str:
if self.content is None:
raise Exception(
"EncryptedDirectMessage `id` is undefined until its message is encrypted and stored in the `content` field"
)
return super().id

147
nostr/key.py Normal file
View file

@ -0,0 +1,147 @@
import secrets
import base64
import secp256k1
from cffi import FFI
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from hashlib import sha256
from .event import EncryptedDirectMessage, Event, EventKind
from . import bech32
class PublicKey:
def __init__(self, raw_bytes: bytes) -> None:
self.raw_bytes = raw_bytes
def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_bytes, 8, 5)
return bech32.bech32_encode("npub", converted_bits, bech32.Encoding.BECH32)
def hex(self) -> str:
return self.raw_bytes.hex()
def verify_signed_message_hash(self, hash: str, sig: str) -> bool:
pk = secp256k1.PublicKey(b"\x02" + self.raw_bytes, True)
return pk.schnorr_verify(bytes.fromhex(hash), bytes.fromhex(sig), None, True)
@classmethod
def from_npub(cls, npub: str):
"""Load a PublicKey from its bech32/npub form"""
hrp, data, spec = bech32.bech32_decode(npub)
raw_public_key = bech32.convertbits(data, 5, 8)[:-1]
return cls(bytes(raw_public_key))
class PrivateKey:
def __init__(self, raw_secret: bytes = None) -> None:
if not raw_secret is None:
self.raw_secret = raw_secret
else:
self.raw_secret = secrets.token_bytes(32)
sk = secp256k1.PrivateKey(self.raw_secret)
self.public_key = PublicKey(sk.pubkey.serialize()[1:])
@classmethod
def from_nsec(cls, nsec: str):
"""Load a PrivateKey from its bech32/nsec form"""
hrp, data, spec = bech32.bech32_decode(nsec)
raw_secret = bech32.convertbits(data, 5, 8)[:-1]
return cls(bytes(raw_secret))
def bech32(self) -> str:
converted_bits = bech32.convertbits(self.raw_secret, 8, 5)
return bech32.bech32_encode("nsec", converted_bits, bech32.Encoding.BECH32)
def hex(self) -> str:
return self.raw_secret.hex()
def tweak_add(self, scalar: bytes) -> bytes:
sk = secp256k1.PrivateKey(self.raw_secret)
return sk.tweak_add(scalar)
def compute_shared_secret(self, public_key_hex: str) -> bytes:
pk = secp256k1.PublicKey(bytes.fromhex("02" + public_key_hex), True)
return pk.ecdh(self.raw_secret, hashfn=copy_x)
def encrypt_message(self, message: str, public_key_hex: str) -> str:
padder = padding.PKCS7(128).padder()
padded_data = padder.update(message.encode()) + padder.finalize()
iv = secrets.token_bytes(16)
cipher = Cipher(
algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)
)
encryptor = cipher.encryptor()
encrypted_message = encryptor.update(padded_data) + encryptor.finalize()
return f"{base64.b64encode(encrypted_message).decode()}?iv={base64.b64encode(iv).decode()}"
def encrypt_dm(self, dm: EncryptedDirectMessage) -> None:
dm.content = self.encrypt_message(
message=dm.cleartext_content, public_key_hex=dm.recipient_pubkey
)
def decrypt_message(self, encoded_message: str, public_key_hex: str) -> str:
encoded_data = encoded_message.split("?iv=")
encoded_content, encoded_iv = encoded_data[0], encoded_data[1]
iv = base64.b64decode(encoded_iv)
cipher = Cipher(
algorithms.AES(self.compute_shared_secret(public_key_hex)), modes.CBC(iv)
)
encrypted_content = base64.b64decode(encoded_content)
decryptor = cipher.decryptor()
decrypted_message = decryptor.update(encrypted_content) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
unpadded_data = unpadder.update(decrypted_message) + unpadder.finalize()
return unpadded_data.decode()
def sign_message_hash(self, hash: bytes) -> str:
sk = secp256k1.PrivateKey(self.raw_secret)
sig = sk.schnorr_sign(hash, None, raw=True)
return sig.hex()
def sign_event(self, event: Event) -> None:
if event.kind == EventKind.ENCRYPTED_DIRECT_MESSAGE and event.content is None:
self.encrypt_dm(event)
if event.public_key is None:
event.public_key = self.public_key.hex()
event.signature = self.sign_message_hash(bytes.fromhex(event.id))
def __eq__(self, other):
return self.raw_secret == other.raw_secret
def mine_vanity_key(prefix: str = None, suffix: str = None) -> PrivateKey:
if prefix is None and suffix is None:
raise ValueError("Expected at least one of 'prefix' or 'suffix' arguments")
while True:
sk = PrivateKey()
if (
prefix is not None
and not sk.public_key.bech32()[5 : 5 + len(prefix)] == prefix
):
continue
if suffix is not None and not sk.public_key.bech32()[-len(suffix) :] == suffix:
continue
break
return sk
ffi = FFI()
@ffi.callback(
"int (unsigned char *, const unsigned char *, const unsigned char *, void *)"
)
def copy_x(output, x32, y32, data):
ffi.memmove(output, x32, 32)
return 1

15
nostr/message_type.py Normal file
View file

@ -0,0 +1,15 @@
class ClientMessageType:
EVENT = "EVENT"
REQUEST = "REQ"
CLOSE = "CLOSE"
class RelayMessageType:
EVENT = "EVENT"
NOTICE = "NOTICE"
END_OF_STORED_EVENTS = "EOSE"
@staticmethod
def is_valid(type: str) -> bool:
if type == RelayMessageType.EVENT or type == RelayMessageType.NOTICE or type == RelayMessageType.END_OF_STORED_EVENTS:
return True
return False

9
services.py Normal file
View file

@ -0,0 +1,9 @@
import re
async def check_lnaddress_format(username: str) -> bool:
# check username complies with lnaddress specification
if not re.match("^[a-z0-9-_.]{3,15}$", username):
assert False, "Only letters a-z0-9-_. allowed, min 3 and max 15 characters!"
return
return True

View file

@ -40,7 +40,9 @@ new Vue({
formDialog: { formDialog: {
show: false, show: false,
fixedAmount: true, fixedAmount: true,
data: {} data: {
zaps:false
}
}, },
qrCodeDialog: { qrCodeDialog: {
show: false, show: false,
@ -140,7 +142,8 @@ new Vue({
'success_url', 'success_url',
'comment_chars', 'comment_chars',
'currency', 'currency',
'username' 'username',
'zaps'
), ),
(value, key) => (value, key) =>
(key === 'webhook_url' || (key === 'webhook_url' ||

View file

@ -8,8 +8,16 @@ from lnbits.core.crud import update_payment_extra
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.helpers import get_current_extension_name from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
from websocket import WebSocketApp
from lnbits.settings import settings
from .crud import get_pay_link from .crud import get_pay_link
from threading import Thread
from . import nostr_privatekey
from typing import List
import time
from .nostr.event import Event
from .nostr.key import PrivateKey, PublicKey
async def wait_for_paid_invoices(): async def wait_for_paid_invoices():
@ -63,11 +71,73 @@ async def on_invoice_paid(payment: Payment):
payment.payment_hash, -1, False, "Unexpected Error", str(ex) payment.payment_hash, -1, False, "Unexpected Error", str(ex)
) )
# NIP-57
# load the zap request
nostr = payment.extra.get("nostr")
if pay_link and pay_link.zaps and nostr:
event_json = json.loads(nostr)
def get_tag(event_json, tag):
res = [
event_tag[1:] for event_tag in event_json["tags"] if event_tag[0] == tag
]
return res[0] if res else None
tags = []
for t in ["p", "e"]:
tag = get_tag(event_json, t)
if tag:
tags.append([t, tag[0]])
tags.append(["bolt11", payment.bolt11])
tags.append(["description", nostr])
zap_receipt = Event(
kind=9735, tags=tags, content=payment.extra.get("comment") or ""
)
nostr_privatekey.sign_event(zap_receipt)
def send_zap(relay):
def send_event(_):
logger.debug(f"Sending zap to {ws.url}")
ws.send(zap_receipt.to_message())
time.sleep(2)
ws.close()
ws = WebSocketApp(relay, on_open=send_event)
wst = Thread(target=ws.run_forever, name=f"LNURL zap {relay}")
wst.daemon = True
wst.start()
return ws, wst
# list of all websockets
wss: List[WebSocketApp] = []
# list of all threads for these websockets
wsts: List[Thread] = []
# # send zap via nostrclient
# ws, wst = send_zap(f"wss://localhost:{settings.port}/nostrclient/api/v1/relay")
# wss += [ws]
# wsts += [wst]
# send zap receipt to relays in zap request
relays = get_tag(event_json, "relays")
if relays:
if len(relays) > 50:
relays = relays[:50]
for r in relays:
ws, wst = send_zap(r)
wss += [ws]
wsts += [wst]
await asyncio.sleep(10)
for ws, wst in zip(wss, wsts):
logger.debug(f"Closing websocket {ws.url}")
ws.close()
wst.join()
async def mark_webhook_sent( async def mark_webhook_sent(
payment_hash: str, status: int, is_success: bool, reason_phrase="", text="" payment_hash: str, status: int, is_success: bool, reason_phrase="", text=""
) -> None: ) -> None:
await update_payment_extra( await update_payment_extra(
payment_hash, payment_hash,
{ {

View file

@ -26,7 +26,7 @@
> >
{% raw %} {% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr class="text-left" :props="props"> <q-tr class="text-left" :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width>Description</q-th> <q-th auto-width>Description</q-th>
<q-th auto-width>Amount</q-th> <q-th auto-width>Amount</q-th>
@ -48,7 +48,8 @@
type="a" type="a"
:href="props.row.pay_url" :href="props.row.pay_url"
target="_blank" target="_blank"
><q-tooltip>Sharable Page</q-tooltip></q-btn> ><q-tooltip>Sharable Page</q-tooltip></q-btn
>
<q-btn <q-btn
unelevated unelevated
dense dense
@ -56,7 +57,8 @@
icon="visibility" icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'" :color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)" @click="openQrCodeDialog(props.row.id)"
><q-tooltip>View Link</q-tooltip></q-btn> ><q-tooltip>View Link</q-tooltip></q-btn
>
</q-td> </q-td>
<q-td auto-width>{{ props.row.description }}</q-td> <q-td auto-width>{{ props.row.description }}</q-td>
<q-td auto-width> <q-td auto-width>
@ -66,10 +68,14 @@
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span> <span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
</q-td> </q-td>
<q-td>{{ props.row.currency || 'sat' }}</q-td> <q-td>{{ props.row.currency || 'sat' }}</q-td>
<q-td auto-width :class="(props.row.username) ? 'text-normal' : 'text-grey'">{{ props.row.username || 'None' }}</q-td> <q-td
auto-width
:class="(props.row.username) ? 'text-normal' : 'text-grey'"
>{{ props.row.username || 'None' }}</q-td
>
<q-td> <q-td>
<q-icon v-if="props.row.webhook_url" size="14px" name="http"> <q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip> <q-tooltip>Webhook to {{ props.row.webhook_url }}</q-tooltip>
</q-icon> </q-icon>
<q-icon <q-icon
v-if="props.row.success_text || props.row.success_url" v-if="props.row.success_text || props.row.success_url"
@ -102,7 +108,7 @@
icon="edit" icon="edit"
color="light-blue" color="light-blue"
> >
<q-tooltip>Edit</q-tooltip> <q-tooltip>Edit</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
flat flat
@ -111,7 +117,8 @@
@click="deletePayLink(props.row.id)" @click="deletePayLink(props.row.id)"
icon="cancel" icon="cancel"
color="pink" color="pink"
><q-tooltip>Delete</q-tooltip></q-btn> ><q-tooltip>Delete</q-tooltip></q-btn
>
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
@ -125,7 +132,7 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<h6 class="text-subtitle1 q-my-none"> <h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} LNURL-pay extension {{ SITE_TITLE }} LNURL-pay extension
</h6> </h6>
</q-card-section> </q-card-section>
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
@ -152,29 +159,30 @@
> >
</q-select> </q-select>
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.description" v-model.trim="formDialog.data.description"
type="text" type="text"
label="Item description *" label="Item description *"
> >
</q-input> </q-input>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.username" v-model.trim="formDialog.data.username"
type="text" type="text"
label="Lightning Address" label="Lightning Address"
> />
</div> </div>
<div class="col" style="margin-top: 10px"> <div class="col" style="margin-top: 10px">
<span class="label"> &nbsp; @ {% raw %} {{domain}} {% endraw %} </span> <span class="label">
&nbsp; @ {% raw %} {{ domain }} {% endraw %}
</span>
</div> </div>
</q-input>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm q-mx-sm">
<q-input <q-input
filled filled
dense dense
@ -209,63 +217,111 @@
v-model="formDialog.data.currency" v-model="formDialog.data.currency"
:display-value="formDialog.data.currency || 'satoshis'" :display-value="formDialog.data.currency || 'satoshis'"
label="Currency" label="Currency"
:hint="'Amounts will be converted at use-time to satoshis. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')" :hint="'Converted to satoshis at each payment. ' + (formDialog.data.currency && fiatRates[formDialog.data.currency] ? `Currently 1 ${formDialog.data.currency} = ${fiatRates[formDialog.data.currency]} sat` : '')"
@input="updateFiatRate" @input="updateFiatRate"
/> />
</div> </div>
</div> </div>
<q-input <q-expansion-item
filled group="advanced"
dense icon="settings"
v-model.number="formDialog.data.comment_chars" label="Advanced options"
type="number"
label="Comment maximum characters"
hint="Tell wallets to prompt users for a comment that will be sent along with the payment. LNURLp will store the comment and send it in the webhook."
> >
</q-input> <q-card>
<q-input <q-card-section>
filled <h5 class="text-caption q-mt-sm q-mb-none">LNURL</h5>
dense <div class="row">
v-model="formDialog.data.webhook_url" <div class="col-12">
type="text" <q-input
label="Webhook URL (optional)" filled
hint="A URL to be called whenever this link receives a payment." dense
></q-input> v-model.number="formDialog.data.comment_chars"
<q-input type="number"
filled label="Comment maximum characters"
dense hint="Allow the payer to attach a comment."
v-if="formDialog.data.webhook_url" >
v-model="formDialog.data.webhook_headers" </q-input>
type="text" </div>
label="Webhook headers (optional)" </div>
hint="Custom data as JSON string, send headers along with the webhook." <div class="row">
></q-input> <div class="col-12">
<q-input <q-input
filled filled
dense dense
v-if="formDialog.data.webhook_url" v-model="formDialog.data.webhook_url"
v-model="formDialog.data.webhook_body" type="text"
type="text" label="Webhook URL (optional)"
label="Webhook custom data (optional)" hint="A URL to be called whenever this link receives a payment."
hint="Custom data as JSON string, will get posted along with webhook 'body' field." ></q-input>
></q-input> </div>
<q-input </div>
filled <div class="row">
dense <div class="col-12">
v-model="formDialog.data.success_text" <q-input
type="text" filled
label="Success message (optional)" dense
hint="Will be shown to the user in his wallet after a successful payment." v-if="formDialog.data.webhook_url"
></q-input> v-model="formDialog.data.webhook_headers"
<q-input type="text"
filled label="Webhook headers (optional)"
dense hint="Custom data as JSON string, send headers along with the webhook."
v-model="formDialog.data.success_url" ></q-input>
type="text" </div>
label="Success URL (optional)" </div>
hint="Will be shown as a clickable link to the user in his wallet after a successful payment, appended by the payment_hash as a query string." <div class="row">
> <div class="col-12">
</q-input> <q-input
filled
dense
v-if="formDialog.data.webhook_url"
v-model="formDialog.data.webhook_body"
type="text"
label="Webhook custom data (optional)"
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
></q-input>
</div>
</div>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.success_text"
type="text"
label="Success message (optional)"
hint="Will be shown to the user in his wallet after a successful payment."
></q-input>
</div>
</div>
<div class="row">
<div class="col-12">
<q-input
filled
dense
v-model="formDialog.data.success_url"
type="text"
label="Success URL (optional)"
hint="Link will be shown to the sender after a successful payment."
>
</q-input>
</div>
</div>
</q-card-section>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">Nostr</h5>
<div class="row">
<div class="col-12">
<q-checkbox
:toggle-indeterminate="false"
dense
v-model="formDialog.data.zaps"
label="Enable nostr zaps"
/>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"
@ -311,17 +367,22 @@
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br /> <strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br /> <strong>Amount:</strong> {{ qrCodeDialog.data.amount }}<br />
<span v-if="qrCodeDialog.data.currency" <span v-if="qrCodeDialog.data.currency"
><strong>{{ qrCodeDialog.data.currency }} price:</strong> {{ ><strong>{{ qrCodeDialog.data.currency }} price:</strong>
fiatRates[qrCodeDialog.data.currency] ? {{
fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}<br fiatRates[qrCodeDialog.data.currency]
? fiatRates[qrCodeDialog.data.currency] + ' sat'
: 'Loading...'
}}<br
/></span> /></span>
<strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments }}<br /> <strong>Accepts comments:</strong> {{ qrCodeDialog.data.comments
}}<br />
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook <strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
}}<br /> }}<br />
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br /> <strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
<span v-if="qrCodeDialog.data.username"> <span v-if="qrCodeDialog.data.username">
<strong>Lightning Address: </strong> {{ qrCodeDialog.data.username}}@{{domain}} <strong>Lightning Address: </strong>
<br/> {{ qrCodeDialog.data.username }}@{{ domain }}
<br />
</span> </span>
</p> </p>
{% endraw %} {% endraw %}