commit
6bce388dcd
16 changed files with 729 additions and 128 deletions
|
|
@ -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\
|
||||||

|

|
||||||
|
|
||||||
- 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\
|
||||||

|

|
||||||
|
|
||||||
|
|
|
||||||
22
__init__.py
22
__init__.py
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,5 @@
|
||||||
"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
22
crud.py
|
|
@ -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
|
||||||
|
|
|
||||||
26
lnurl.py
26
lnurl.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
{
|
{
|
||||||
"repos": [
|
"repos": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;")
|
||||||
|
|
|
||||||
|
|
@ -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
137
nostr/bech32.py
Normal 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
126
nostr/event.py
Normal 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
147
nostr/key.py
Normal 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
15
nostr/message_type.py
Normal 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
9
services.py
Normal 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
|
||||||
|
|
@ -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' ||
|
||||||
|
|
|
||||||
74
tasks.py
74
tasks.py
|
|
@ -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,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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">
|
||||||
|
|
@ -167,14 +174,15 @@
|
||||||
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"> @ {% raw %} {{domain}} {% endraw %} </span>
|
<span class="label">
|
||||||
|
@ {% 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,20 +217,34 @@
|
||||||
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-expansion-item
|
||||||
|
group="advanced"
|
||||||
|
icon="settings"
|
||||||
|
label="Advanced options"
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>
|
||||||
|
<h5 class="text-caption q-mt-sm q-mb-none">LNURL</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model.number="formDialog.data.comment_chars"
|
v-model.number="formDialog.data.comment_chars"
|
||||||
type="number"
|
type="number"
|
||||||
label="Comment maximum characters"
|
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."
|
hint="Allow the payer to attach a comment."
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -231,6 +253,10 @@
|
||||||
label="Webhook URL (optional)"
|
label="Webhook URL (optional)"
|
||||||
hint="A URL to be called whenever this link receives a payment."
|
hint="A URL to be called whenever this link receives a payment."
|
||||||
></q-input>
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -240,6 +266,10 @@
|
||||||
label="Webhook headers (optional)"
|
label="Webhook headers (optional)"
|
||||||
hint="Custom data as JSON string, send headers along with the webhook."
|
hint="Custom data as JSON string, send headers along with the webhook."
|
||||||
></q-input>
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -249,6 +279,10 @@
|
||||||
label="Webhook custom data (optional)"
|
label="Webhook custom data (optional)"
|
||||||
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
||||||
></q-input>
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
|
|
@ -257,15 +291,37 @@
|
||||||
label="Success message (optional)"
|
label="Success message (optional)"
|
||||||
hint="Will be shown to the user in his wallet after a successful payment."
|
hint="Will be shown to the user in his wallet after a successful payment."
|
||||||
></q-input>
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
<q-input
|
<q-input
|
||||||
filled
|
filled
|
||||||
dense
|
dense
|
||||||
v-model="formDialog.data.success_url"
|
v-model="formDialog.data.success_url"
|
||||||
type="text"
|
type="text"
|
||||||
label="Success URL (optional)"
|
label="Success URL (optional)"
|
||||||
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."
|
hint="Link will be shown to the sender after a successful payment."
|
||||||
>
|
>
|
||||||
</q-input>
|
</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 %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue