diff --git a/README.md b/README.md
index 0832bfb..8957dff 100644
--- a/README.md
+++ b/README.md
@@ -25,3 +25,8 @@ LNURL is a range of lightning-network standards that allow us to use lightning-n

- you can now open your LNURLp and copy the LNURL, get the shareable link or print it\

+
+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
diff --git a/__init__.py b/__init__.py
index aa13bb9..4ef4c7d 100644
--- a/__init__.py
+++ b/__init__.py
@@ -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"])
diff --git a/crud.py b/crud.py
index 4acb4a4..aeb78d0 100644
--- a/crud.py
+++ b/crud.py
@@ -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,))
diff --git a/lnurl.py b/lnurl.py
index 918a5bd..0f94f54 100644
--- a/lnurl.py
+++ b/lnurl.py
@@ -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
diff --git a/migrations.py b/migrations.py
index 1ec85eb..879bec3 100644
--- a/migrations.py
+++ b/migrations.py
@@ -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;")
diff --git a/models.py b/models.py
index de66d40..9611d41 100644
--- a/models.py
+++ b/models.py
@@ -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))
diff --git a/static/js/index.js b/static/js/index.js
index c1372be..c44c4ca 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -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' ||
diff --git a/templates/lnurlp/_api_docs.html b/templates/lnurlp/_api_docs.html
index abb37e9..9c07eba 100644
--- a/templates/lnurlp/_api_docs.html
+++ b/templates/lnurlp/_api_docs.html
@@ -62,7 +62,7 @@
{"description": <string> "amount": <integer> "max":
<integer> "min": <integer> "comment_chars":
- <integer>}