Merge pull request #2 from bitkarrot/lnaddr

Lightning address support
This commit is contained in:
calle 2023-03-15 12:22:23 +01:00 committed by GitHub
commit bb69239663
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 217 additions and 64 deletions

View file

@ -25,3 +25,8 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n
![LNURLp](https://i.imgur.com/C8s1P0Q.jpg)
- 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)
3. Optional - add Lightning Address
- attach a username to your lnurlp to create a lightning address
- the LN address format will be username@lnbits-domain-name
- Find out more about the lightning address spec at lightningaddress.com

View file

@ -17,6 +17,14 @@ lnurlp_static_files = [
"name": "lnurlp_static",
}
]
lnurlp_redirect_paths = [
{
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
}
]
scheduled_tasks: List[asyncio.Task] = []
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])

65
crud.py
View file

@ -1,12 +1,51 @@
import re
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from . import db # , maindb
from .models import CreatePayLinkData, PayLink
# from loguru import logger
async def check_lnaddress_update(username: str, id: str) -> bool:
# check no duplicates for lnaddress when updating an username
row = await db.fetchall(
"SELECT username FROM lnurlp.pay_links WHERE username = ? AND id = ?",
(username, id),
)
if len(row) > 1:
assert False, "Username already exists. Try a different one."
return
else:
return True
async def check_lnaddress_not_exists(username: str) -> bool:
# check if lnaddress username exists in the database when creating a new entry
row = await db.fetchall(
"SELECT username FROM lnurlp.pay_links WHERE username = ?", (username,)
)
if row:
assert False, "Username already exists. Try a different one."
else:
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:
if data.username:
await check_lnaddress_format(data.username)
await check_lnaddress_not_exists(data.username)
link_id = urlsafe_short_hash()[:6]
result = await db.execute(
@ -26,9 +65,11 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
success_url,
comment_chars,
currency,
fiat_base_multiplier
fiat_base_multiplier,
username
)
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
link_id,
@ -44,6 +85,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
data.comment_chars,
data.currency,
data.fiat_base_multiplier,
data.username,
),
)
assert result
@ -53,6 +95,13 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
return link
async def get_address_data(username: str) -> Optional[PayLink]:
row = await db.fetchone(
"SELECT * FROM lnurlp.pay_links WHERE username = ?", (username,)
)
return PayLink.from_row(row) if row else None
async def get_pay_link(link_id: str) -> Optional[PayLink]:
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
@ -73,7 +122,11 @@ async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
return [PayLink.from_row(row) for row in rows]
async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
async def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
if "lnaddress" in kwargs:
await check_lnaddress_format(kwargs["lnaddress"])
await check_lnaddress_update(kwargs["lnaddress"], link_id)
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
@ -82,7 +135,7 @@ async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
return PayLink.from_row(row) if row else None
async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
async def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
await db.execute(
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
@ -91,5 +144,5 @@ async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
return PayLink.from_row(row) if row else None
async def delete_pay_link(link_id: int) -> None:
async def delete_pay_link(link_id: str) -> None:
await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,))

113
lnurl.py
View file

@ -1,6 +1,6 @@
from http import HTTPStatus
from fastapi import Request
from fastapi import Request, Query
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
from starlette.exceptions import HTTPException
@ -8,40 +8,20 @@ from lnbits.core.services import create_invoice
from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from . import lnurlp_ext
from .crud import increment_pay_link
from .crud import increment_pay_link, get_pay_link, get_address_data
from loguru import logger
from urllib.parse import urlparse
@lnurlp_ext.get(
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL)
"/api/v1/lnurl/cb/lnaddr/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response.deprecated",
name="lnurlp.api_lnurl_lnaddr_callback",
)
@lnurlp_ext.get(
"/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response",
)
async def api_lnurl_response(request: Request, link_id):
link = await increment_pay_link(link_id, served_meta=1)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
)
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
resp = LnurlPayResponse(
callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
min_sendable=round(link.min * rate) * 1000,
max_sendable=round(link.max * rate) * 1000,
metadata=link.lnurlpay_metadata,
)
params = resp.dict()
if link.comment_chars > 0:
params["commentAllowed"] = link.comment_chars
return params
async def api_lnurl_lnaddr_callback(
request: Request, link_id, amount: int = Query(...)
):
return await api_lnurl_callback(request, link_id, amount, lnaddress=True)
@lnurlp_ext.get(
@ -49,7 +29,9 @@ async def api_lnurl_response(request: Request, link_id):
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_callback",
)
async def api_lnurl_callback(request: Request, link_id):
async def api_lnurl_callback(
request: Request, link_id, amount: int = Query(...), lnaddress=False
):
link = await increment_pay_link(link_id, served_pr=1)
if not link:
raise HTTPException(
@ -65,7 +47,7 @@ async def api_lnurl_callback(request: Request, link_id):
min = link.min * 1000
max = link.max * 1000
amount_received = int(request.query_params.get("amount") or 0)
amount_received = amount
if amount_received < min:
return LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}."
@ -82,25 +64,76 @@ async def api_lnurl_callback(request: Request, link_id):
reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
).dict()
if lnaddress:
# for lnaddress, we have to set this otherwise the metadata won't have the identifier
link.domain = urlparse(str(request.url)).netloc
extra = {
"tag": "lnurlp",
"link": link.id,
"extra": request.query_params.get("amount"),
}
if comment:
extra["comment"] = (comment,)
if lnaddress and link.username and link.domain:
extra["lnaddress"] = f"{link.username}@{link.domain}"
payment_hash, payment_request = await create_invoice(
wallet_id=link.wallet,
amount=int(amount_received / 1000),
memo=link.description,
unhashed_description=link.lnurlpay_metadata.encode(),
extra={
"tag": "lnurlp",
"link": link.id,
"comment": comment,
"extra": request.query_params.get("amount"),
},
extra=extra,
)
success_action = link.success_action(payment_hash)
if success_action:
resp = LnurlPayActionResponse(
pr=payment_request, success_action=success_action, routes=[]
pr=payment_request, success_action=success_action, routes=[] # type: ignore
)
else:
resp = LnurlPayActionResponse(pr=payment_request, routes=[])
resp = LnurlPayActionResponse(pr=payment_request, routes=[]) # type: ignore
return resp.dict()
@lnurlp_ext.get(
"/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL)
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response.deprecated",
)
@lnurlp_ext.get(
"/{link_id}",
status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response",
)
async def api_lnurl_response(request: Request, link_id, lnaddress=False):
link = await increment_pay_link(link_id, served_meta=1)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
)
rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
if lnaddress:
# for lnaddress, we have to set this otherwise the metadata won't have the identifier
link.domain = urlparse(str(request.url)).netloc
callback = request.url_for("lnurlp.api_lnurl_lnaddr_callback", link_id=link.id)
else:
callback = request.url_for("lnurlp.api_lnurl_callback", link_id=link.id)
resp = LnurlPayResponse(
callback=callback,
min_sendable=round(link.min * rate) * 1000, # type: ignore
max_sendable=round(link.max * rate) * 1000, # type: ignore
metadata=link.lnurlpay_metadata,
)
params = resp.dict()
if link.comment_chars > 0:
params["commentAllowed"] = link.comment_chars
return params

View file

@ -146,3 +146,10 @@ async def m006_redux(db):
)
await db.execute("DROP TABLE lnurlp.pay_links_old")
async def m007_add_lnaddress_username(db):
"""
Add headers and body to webhooks
"""
await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN username TEXT;")

View file

@ -23,6 +23,7 @@ class CreatePayLinkData(BaseModel):
success_text: str = Query(None)
success_url: str = Query(None)
fiat_base_multiplier: int = Query(100, ge=1)
username: str = Query(None)
class PayLink(BaseModel):
@ -32,6 +33,8 @@ class PayLink(BaseModel):
min: float
served_meta: int
served_pr: int
username: Optional[str]
domain: Optional[str]
webhook_url: Optional[str]
webhook_headers: Optional[str]
webhook_body: Optional[str]
@ -54,10 +57,6 @@ class PayLink(BaseModel):
url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
return lnurl_encode(url)
@property
def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
def success_action(self, payment_hash: str) -> Optional[Dict]:
if self.success_url:
url: ParseResult = urlparse(self.success_url)
@ -73,3 +72,14 @@ class PayLink(BaseModel):
return {"tag": "message", "message": self.success_text}
else:
return None
@property
def lnurlpay_metadata(self) -> LnurlPayMetadata:
if self.domain and self.username:
text = f"Payment to {self.username}"
identifier = f"{self.username}@{self.domain}"
metadata = [["text/plain", text], ["text/identifier", identifier]]
else:
metadata = [["text/plain", self.description]]
return LnurlPayMetadata(json.dumps(metadata))

View file

@ -9,6 +9,8 @@ var locationPath = [
window.location.pathname
].join('')
var domain = window.location.hostname
var mapPayLink = obj => {
obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
@ -26,6 +28,7 @@ new Vue({
mixins: [windowMixin],
data() {
return {
domain: domain,
currencies: [],
fiatRates: {},
checker: null,
@ -90,7 +93,8 @@ new Vue({
: 'do nothing',
lnurl: link.lnurl,
pay_url: link.pay_url,
print_url: link.print_url
print_url: link.print_url,
username: link.username
}
this.qrCodeDialog.show = true
},
@ -137,7 +141,8 @@ new Vue({
'success_text',
'success_url',
'comment_chars',
'currency'
'currency',
'username'
),
(value, key) =>
(key === 'webhook_url' ||

View file

@ -62,7 +62,7 @@
<code
>{"description": &lt;string&gt; "amount": &lt;integer&gt; "max":
&lt;integer&gt; "min": &lt;integer&gt; "comment_chars":
&lt;integer&gt;}</code
&lt;integer&gt; "username": &lt;string&gt; }</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)

View file

@ -26,11 +26,12 @@
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-tr class="text-left" :props="props">
<q-th auto-width></q-th>
<q-th auto-width>Description</q-th>
<q-th auto-width>Amount</q-th>
<q-th auto-width>Currency</q-th>
<q-th auto-width>Username</q-th>
<q-th auto-width></q-th>
<q-th auto-width></q-th>
</q-tr>
@ -47,7 +48,7 @@
type="a"
:href="props.row.pay_url"
target="_blank"
></q-btn>
><q-tooltip>Sharable Page</q-tooltip></q-btn>
<q-btn
unelevated
dense
@ -55,7 +56,7 @@
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
></q-btn>
><q-tooltip>View Link</q-tooltip></q-btn>
</q-td>
<q-td auto-width>{{ props.row.description }}</q-td>
<q-td auto-width>
@ -65,6 +66,7 @@
<span v-else>{{ props.row.min }} - {{ props.row.max }}</span>
</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>
<q-icon v-if="props.row.webhook_url" size="14px" name="http">
<q-tooltip>Webhook to {{ props.row.webhook_url}}</q-tooltip>
@ -100,6 +102,7 @@
icon="edit"
color="light-blue"
>
<q-tooltip>Edit</q-tooltip>
</q-btn>
<q-btn
flat
@ -108,7 +111,7 @@
@click="deletePayLink(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
><q-tooltip>Delete</q-tooltip></q-btn>
</q-td>
</q-tr>
</template>
@ -149,13 +152,28 @@
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.description"
type="text"
label="Item description *"
>
</q-input>
filled
dense
v-model.trim="formDialog.data.description"
type="text"
label="Item description *"
>
</q-input>
<div class="row">
<div class="col">
<q-input
filled
dense
v-model.trim="formDialog.data.username"
type="text"
label="Lightning Address"
>
</div>
<div class="col" style="margin-top: 10px">
<span class="label"> &nbsp; @ {% raw %} {{domain}} {% endraw %} </span>
</div>
</q-input>
</div>
<div class="row q-col-gutter-sm">
<q-input
filled
@ -301,6 +319,10 @@
<strong>Dispatches webhook to:</strong> {{ qrCodeDialog.data.webhook
}}<br />
<strong>On success:</strong> {{ qrCodeDialog.data.success }}<br />
<span v-if="qrCodeDialog.data.username">
<strong>Lightning Address: </strong> {{ qrCodeDialog.data.username}}@{{domain}}
<br/>
</span>
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">

View file

@ -1,6 +1,7 @@
import json
from asyncio.log import logger
from http import HTTPStatus
from urllib.parse import urlparse
from fastapi import Depends, Query, Request
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
@ -17,8 +18,17 @@ from .crud import (
get_pay_link,
get_pay_links,
update_pay_link,
get_address_data,
)
from .models import CreatePayLinkData
from .lnurl import api_lnurl_response
# redirected from /.well-known/lnurlp
@lnurlp_ext.get("/api/v1/well-known/{username}")
async def lnaddress(username: str, request: Request):
address_data = await get_address_data(username)
assert address_data, "User not found"
return await api_lnurl_response(request, address_data.id, lnaddress=True)
@lnurlp_ext.get("/api/v1/currencies")