feat: new lnurl lib and types on endpoints (#57)
This commit is contained in:
parent
b42fee99e5
commit
717d9c88f8
4 changed files with 1221 additions and 464 deletions
|
|
@ -2,7 +2,8 @@
|
||||||
"name": "Withdraw Links",
|
"name": "Withdraw Links",
|
||||||
"short_description": "Make LNURL withdraw links",
|
"short_description": "Make LNURL withdraw links",
|
||||||
"tile": "/withdraw/static/image/lnurl-withdraw.png",
|
"tile": "/withdraw/static/image/lnurl-withdraw.png",
|
||||||
"min_lnbits_version": "1.0.0",
|
"version": "1.1.0",
|
||||||
|
"min_lnbits_version": "1.3.0",
|
||||||
"contributors": [
|
"contributors": [
|
||||||
{
|
{
|
||||||
"name": "arcbtc",
|
"name": "arcbtc",
|
||||||
|
|
|
||||||
1519
poetry.lock
generated
1519
poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ name = "lnbits-withdraw"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||||
authors = ["Alan Bits <alan@lnbits.com>"]
|
authors = ["Alan Bits <alan@lnbits.com>"]
|
||||||
|
package-mode = false
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "~3.12 | ~3.11 | ~3.10"
|
python = "~3.12 | ~3.11 | ~3.10"
|
||||||
|
|
|
||||||
154
views_lnurl.py
154
views_lnurl.py
|
|
@ -1,18 +1,23 @@
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from http import HTTPStatus
|
from typing import Optional
|
||||||
from typing import Callable, Optional
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.routing import APIRoute
|
|
||||||
from lnbits.core.crud import update_payment
|
from lnbits.core.crud import update_payment
|
||||||
from lnbits.core.models import Payment
|
from lnbits.core.models import Payment
|
||||||
from lnbits.core.services import pay_invoice
|
from lnbits.core.services import pay_invoice
|
||||||
|
from lnurl import (
|
||||||
|
CallbackUrl,
|
||||||
|
LnurlErrorResponse,
|
||||||
|
LnurlSuccessResponse,
|
||||||
|
LnurlWithdrawResponse,
|
||||||
|
MilliSatoshi,
|
||||||
|
)
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from pydantic import parse_obj_as
|
||||||
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
create_hash_check,
|
create_hash_check,
|
||||||
|
|
@ -23,28 +28,7 @@ from .crud import (
|
||||||
)
|
)
|
||||||
from .models import WithdrawLink
|
from .models import WithdrawLink
|
||||||
|
|
||||||
|
|
||||||
class LNURLErrorResponseHandler(APIRoute):
|
|
||||||
def get_route_handler(self) -> Callable:
|
|
||||||
original_route_handler = super().get_route_handler()
|
|
||||||
|
|
||||||
async def custom_route_handler(request: Request) -> Response:
|
|
||||||
try:
|
|
||||||
response = await original_route_handler(request)
|
|
||||||
return response
|
|
||||||
except HTTPException as exc:
|
|
||||||
logger.debug(f"HTTPException: {exc}")
|
|
||||||
response = JSONResponse(
|
|
||||||
status_code=200,
|
|
||||||
content={"status": "ERROR", "reason": f"{exc.detail}"},
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
return custom_route_handler
|
|
||||||
|
|
||||||
|
|
||||||
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
|
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
|
||||||
withdraw_ext_lnurl.route_class = LNURLErrorResponseHandler
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_lnurl.get(
|
@withdraw_ext_lnurl.get(
|
||||||
|
|
@ -52,45 +36,32 @@ withdraw_ext_lnurl.route_class = LNURLErrorResponseHandler
|
||||||
response_class=JSONResponse,
|
response_class=JSONResponse,
|
||||||
name="withdraw.api_lnurl_response",
|
name="withdraw.api_lnurl_response",
|
||||||
)
|
)
|
||||||
async def api_lnurl_response(request: Request, unique_hash: str):
|
async def api_lnurl_response(
|
||||||
|
request: Request, unique_hash: str
|
||||||
|
) -> LnurlWithdrawResponse | LnurlErrorResponse:
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="Withdraw link does not exist.")
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
|
|
||||||
)
|
|
||||||
|
|
||||||
if link.is_spent:
|
if link.is_spent:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="Withdraw is spent.")
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
|
||||||
)
|
|
||||||
|
|
||||||
if link.is_unique:
|
if link.is_unique:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="This link requires an id_unique_hash.")
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="This link requires an id_unique_hash.",
|
|
||||||
)
|
|
||||||
|
|
||||||
url = str(
|
url = str(
|
||||||
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if url is .onion and change to http
|
callback_url = parse_obj_as(CallbackUrl, url)
|
||||||
if urlparse(url).netloc.endswith(".onion"):
|
return LnurlWithdrawResponse(
|
||||||
# change url string scheme to http
|
callback=callback_url,
|
||||||
url = url.replace("https://", "http://")
|
k1=link.k1,
|
||||||
|
minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000),
|
||||||
return {
|
maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000),
|
||||||
"tag": "withdrawRequest",
|
defaultDescription=link.title,
|
||||||
"callback": url,
|
)
|
||||||
"k1": link.k1,
|
|
||||||
"minWithdrawable": link.min_withdrawable * 1000,
|
|
||||||
"maxWithdrawable": link.max_withdrawable * 1000,
|
|
||||||
"defaultDescription": link.title,
|
|
||||||
"webhook_url": link.webhook_url,
|
|
||||||
"webhook_headers": link.webhook_headers,
|
|
||||||
"webhook_body": link.webhook_body,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@withdraw_ext_lnurl.get(
|
@withdraw_ext_lnurl.get(
|
||||||
|
|
@ -115,52 +86,40 @@ async def api_lnurl_callback(
|
||||||
k1: str,
|
k1: str,
|
||||||
pr: str,
|
pr: str,
|
||||||
id_unique_hash: Optional[str] = None,
|
id_unique_hash: Optional[str] = None,
|
||||||
):
|
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
||||||
|
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="withdraw link not found.")
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
|
|
||||||
)
|
|
||||||
|
|
||||||
if link.is_spent:
|
if link.is_spent:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="withdraw is spent.")
|
||||||
status_code=HTTPStatus.METHOD_NOT_ALLOWED, detail="withdraw is spent."
|
|
||||||
)
|
|
||||||
|
|
||||||
if link.k1 != k1:
|
if link.k1 != k1:
|
||||||
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="k1 is wrong.")
|
return LnurlErrorResponse(reason="k1 is wrong.")
|
||||||
|
|
||||||
now = int(datetime.now().timestamp())
|
now = int(datetime.now().timestamp())
|
||||||
|
|
||||||
if now < link.open_time:
|
if now < link.open_time:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
reason=f"wait link open_time {link.open_time - now} seconds."
|
||||||
detail=f"wait link open_time {link.open_time - now} seconds.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not id_unique_hash and link.is_unique:
|
if not id_unique_hash and link.is_unique:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="id_unique_hash is required for this link.")
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
|
||||||
detail="id_unique_hash is required for this link.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if id_unique_hash:
|
if id_unique_hash:
|
||||||
if check_unique_link(link, id_unique_hash):
|
if check_unique_link(link, id_unique_hash):
|
||||||
await remove_unique_withdraw_link(link, id_unique_hash)
|
await remove_unique_withdraw_link(link, id_unique_hash)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="id_unique_hash not found.")
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="withdraw not found."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a record with the id_unique_hash or unique_hash, if it already exists,
|
# Create a record with the id_unique_hash or unique_hash, if it already exists,
|
||||||
# raise an exception thus preventing the same LNURL from being processed twice.
|
# raise an exception thus preventing the same LNURL from being processed twice.
|
||||||
try:
|
try:
|
||||||
await create_hash_check(id_unique_hash or unique_hash, k1)
|
await create_hash_check(id_unique_hash or unique_hash, k1)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="LNURL already being processed.")
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="LNURL already being processed."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payment = await pay_invoice(
|
payment = await pay_invoice(
|
||||||
|
|
@ -177,13 +136,11 @@ async def api_lnurl_callback(
|
||||||
|
|
||||||
if link.webhook_url:
|
if link.webhook_url:
|
||||||
await dispatch_webhook(link, payment, pr)
|
await dispatch_webhook(link, payment, pr)
|
||||||
return {"status": "OK"}
|
return LnurlSuccessResponse()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# If payment fails, delete the hash stored so another attempt can be made.
|
# If payment fails, delete the hash stored so another attempt can be made.
|
||||||
await delete_hash_check(id_unique_hash or unique_hash)
|
await delete_hash_check(id_unique_hash or unique_hash)
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason=f"withdraw not working. {exc!s}")
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail=f"withdraw not working. {exc!s}"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
|
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
|
||||||
|
|
@ -232,38 +189,25 @@ async def dispatch_webhook(
|
||||||
)
|
)
|
||||||
async def api_lnurl_multi_response(
|
async def api_lnurl_multi_response(
|
||||||
request: Request, unique_hash: str, id_unique_hash: str
|
request: Request, unique_hash: str, id_unique_hash: str
|
||||||
):
|
) -> LnurlWithdrawResponse | LnurlErrorResponse:
|
||||||
link = await get_withdraw_link_by_hash(unique_hash)
|
link = await get_withdraw_link_by_hash(unique_hash)
|
||||||
|
|
||||||
if not link:
|
if not link:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="Withdraw link does not exist.")
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
|
||||||
)
|
|
||||||
|
|
||||||
if link.is_spent:
|
if link.is_spent:
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="Withdraw is spent.")
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw is spent."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not check_unique_link(link, id_unique_hash):
|
if not check_unique_link(link, id_unique_hash):
|
||||||
raise HTTPException(
|
return LnurlErrorResponse(reason="id_unique_hash not found for this link.")
|
||||||
status_code=HTTPStatus.NOT_FOUND, detail="LNURL-withdraw not found."
|
|
||||||
)
|
|
||||||
|
|
||||||
url = str(
|
url = request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
||||||
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
|
||||||
|
callback_url = parse_obj_as(CallbackUrl, f"{url!s}?id_unique_hash={id_unique_hash}")
|
||||||
|
return LnurlWithdrawResponse(
|
||||||
|
callback=callback_url,
|
||||||
|
k1=link.k1,
|
||||||
|
minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000),
|
||||||
|
maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000),
|
||||||
|
defaultDescription=link.title,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if url is .onion and change to http
|
|
||||||
if urlparse(url).netloc.endswith(".onion"):
|
|
||||||
# change url string scheme to http
|
|
||||||
url = url.replace("https://", "http://")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"tag": "withdrawRequest",
|
|
||||||
"callback": f"{url}?id_unique_hash={id_unique_hash}",
|
|
||||||
"k1": link.k1,
|
|
||||||
"minWithdrawable": link.min_withdrawable * 1000,
|
|
||||||
"maxWithdrawable": link.max_withdrawable * 1000,
|
|
||||||
"defaultDescription": link.title,
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue