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) ![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)
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", "name": "lnurlp_static",
} }
] ]
lnurlp_redirect_paths = [
{
"from_path": "/.well-known/lnurlp",
"redirect_to_path": "/api/v1/well-known",
}
]
scheduled_tasks: List[asyncio.Task] = [] scheduled_tasks: List[asyncio.Task] = []
lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"]) 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 typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from . import db from . import db # , maindb
from .models import CreatePayLinkData, PayLink 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: 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] link_id = urlsafe_short_hash()[:6]
result = await db.execute( result = await db.execute(
@ -26,9 +65,11 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
success_url, success_url,
comment_chars, comment_chars,
currency, currency,
fiat_base_multiplier fiat_base_multiplier,
username
) )
VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
link_id, link_id,
@ -44,6 +85,7 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
data.comment_chars, data.comment_chars,
data.currency, data.currency,
data.fiat_base_multiplier, data.fiat_base_multiplier,
data.username,
), ),
) )
assert result assert result
@ -53,6 +95,13 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
return link 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]: async def get_pay_link(link_id: str) -> Optional[PayLink]:
row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,)) row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None 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] 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()]) q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) 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 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()]) q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
await db.execute( await db.execute(
f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id) 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 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,)) 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 http import HTTPStatus
from fastapi import Request from fastapi import Request, Query
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
from starlette.exceptions import HTTPException 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 lnbits.utils.exchange_rates import get_fiat_rate_satoshis
from . import lnurlp_ext 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( @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, status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_response.deprecated", name="lnurlp.api_lnurl_lnaddr_callback",
) )
@lnurlp_ext.get( async def api_lnurl_lnaddr_callback(
"/{link_id}", request: Request, link_id, amount: int = Query(...)
status_code=HTTPStatus.OK, ):
name="lnurlp.api_lnurl_response", return await api_lnurl_callback(request, link_id, amount, lnaddress=True)
)
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
@lnurlp_ext.get( @lnurlp_ext.get(
@ -49,7 +29,9 @@ async def api_lnurl_response(request: Request, link_id):
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
name="lnurlp.api_lnurl_callback", 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) link = await increment_pay_link(link_id, served_pr=1)
if not link: if not link:
raise HTTPException( raise HTTPException(
@ -65,7 +47,7 @@ async def api_lnurl_callback(request: Request, link_id):
min = link.min * 1000 min = link.min * 1000
max = link.max * 1000 max = link.max * 1000
amount_received = int(request.query_params.get("amount") or 0) amount_received = amount
if amount_received < min: if amount_received < min:
return LnurlErrorResponse( return LnurlErrorResponse(
reason=f"Amount {amount_received} is smaller than minimum {min}." 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}" reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
).dict() ).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( payment_hash, payment_request = await create_invoice(
wallet_id=link.wallet, wallet_id=link.wallet,
amount=int(amount_received / 1000), amount=int(amount_received / 1000),
memo=link.description, memo=link.description,
unhashed_description=link.lnurlpay_metadata.encode(), unhashed_description=link.lnurlpay_metadata.encode(),
extra={ extra=extra,
"tag": "lnurlp",
"link": link.id,
"comment": comment,
"extra": request.query_params.get("amount"),
},
) )
success_action = link.success_action(payment_hash) success_action = link.success_action(payment_hash)
if success_action: if success_action:
resp = LnurlPayActionResponse( resp = LnurlPayActionResponse(
pr=payment_request, success_action=success_action, routes=[] pr=payment_request, success_action=success_action, routes=[] # type: ignore
) )
else: else:
resp = LnurlPayActionResponse(pr=payment_request, routes=[]) resp = LnurlPayActionResponse(pr=payment_request, routes=[]) # type: ignore
return resp.dict() 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") 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_text: str = Query(None)
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)
class PayLink(BaseModel): class PayLink(BaseModel):
@ -32,6 +33,8 @@ class PayLink(BaseModel):
min: float min: float
served_meta: int served_meta: int
served_pr: int served_pr: int
username: Optional[str]
domain: Optional[str]
webhook_url: Optional[str] webhook_url: Optional[str]
webhook_headers: Optional[str] webhook_headers: Optional[str]
webhook_body: 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) url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
return lnurl_encode(url) 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]: def success_action(self, payment_hash: str) -> Optional[Dict]:
if self.success_url: if self.success_url:
url: ParseResult = urlparse(self.success_url) url: ParseResult = urlparse(self.success_url)
@ -73,3 +72,14 @@ class PayLink(BaseModel):
return {"tag": "message", "message": self.success_text} return {"tag": "message", "message": self.success_text}
else: else:
return None 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 window.location.pathname
].join('') ].join('')
var domain = window.location.hostname
var mapPayLink = obj => { var mapPayLink = obj => {
obj._data = _.clone(obj) obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate( obj.date = Quasar.utils.date.formatDate(
@ -26,6 +28,7 @@ new Vue({
mixins: [windowMixin], mixins: [windowMixin],
data() { data() {
return { return {
domain: domain,
currencies: [], currencies: [],
fiatRates: {}, fiatRates: {},
checker: null, checker: null,
@ -90,7 +93,8 @@ new Vue({
: 'do nothing', : 'do nothing',
lnurl: link.lnurl, lnurl: link.lnurl,
pay_url: link.pay_url, pay_url: link.pay_url,
print_url: link.print_url print_url: link.print_url,
username: link.username
} }
this.qrCodeDialog.show = true this.qrCodeDialog.show = true
}, },
@ -137,7 +141,8 @@ new Vue({
'success_text', 'success_text',
'success_url', 'success_url',
'comment_chars', 'comment_chars',
'currency' 'currency',
'username'
), ),
(value, key) => (value, key) =>
(key === 'webhook_url' || (key === 'webhook_url' ||

View file

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

View file

@ -26,11 +26,12 @@
> >
{% raw %} {% raw %}
<template v-slot:header="props"> <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></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>
<q-th auto-width>Currency</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-th auto-width></q-th> <q-th auto-width></q-th>
</q-tr> </q-tr>
@ -47,7 +48,7 @@
type="a" type="a"
:href="props.row.pay_url" :href="props.row.pay_url"
target="_blank" target="_blank"
></q-btn> ><q-tooltip>Sharable Page</q-tooltip></q-btn>
<q-btn <q-btn
unelevated unelevated
dense dense
@ -55,7 +56,7 @@
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-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>
@ -65,6 +66,7 @@
<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> <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>
@ -100,6 +102,7 @@
icon="edit" icon="edit"
color="light-blue" color="light-blue"
> >
<q-tooltip>Edit</q-tooltip>
</q-btn> </q-btn>
<q-btn <q-btn
flat flat
@ -108,7 +111,7 @@
@click="deletePayLink(props.row.id)" @click="deletePayLink(props.row.id)"
icon="cancel" icon="cancel"
color="pink" color="pink"
></q-btn> ><q-tooltip>Delete</q-tooltip></q-btn>
</q-td> </q-td>
</q-tr> </q-tr>
</template> </template>
@ -149,13 +152,28 @@
> >
</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="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"> <div class="row q-col-gutter-sm">
<q-input <q-input
filled filled
@ -301,6 +319,10 @@
<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">
<strong>Lightning Address: </strong> {{ qrCodeDialog.data.username}}@{{domain}}
<br/>
</span>
</p> </p>
{% endraw %} {% endraw %}
<div class="row q-mt-lg q-gutter-sm"> <div class="row q-mt-lg q-gutter-sm">

View file

@ -1,6 +1,7 @@
import json import json
from asyncio.log import logger from asyncio.log import logger
from http import HTTPStatus from http import HTTPStatus
from urllib.parse import urlparse
from fastapi import Depends, Query, Request from fastapi import Depends, Query, Request
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
@ -17,8 +18,17 @@ from .crud import (
get_pay_link, get_pay_link,
get_pay_links, get_pay_links,
update_pay_link, update_pay_link,
get_address_data,
) )
from .models import CreatePayLinkData 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") @lnurlp_ext.get("/api/v1/currencies")