231 lines
7.8 KiB
Python
231 lines
7.8 KiB
Python
import json
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
import shortuuid
|
|
from bolt11 import decode as decode_bolt11
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse
|
|
from lnbits.core.crud import update_payment
|
|
from lnbits.core.models import Payment
|
|
from lnbits.core.services import pay_invoice
|
|
from lnurl import (
|
|
CallbackUrl,
|
|
LnurlErrorResponse,
|
|
LnurlSuccessResponse,
|
|
LnurlWithdrawResponse,
|
|
MilliSatoshi,
|
|
)
|
|
from loguru import logger
|
|
from pydantic import parse_obj_as
|
|
|
|
from .crud import (
|
|
create_hash_check,
|
|
delete_hash_check,
|
|
get_withdraw_link_by_hash,
|
|
increment_withdraw_link,
|
|
remove_unique_withdraw_link,
|
|
)
|
|
from .models import WithdrawLink
|
|
|
|
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
|
|
|
|
|
|
@withdraw_ext_lnurl.get(
|
|
"/{unique_hash}",
|
|
response_class=JSONResponse,
|
|
name="withdraw.api_lnurl_response",
|
|
)
|
|
async def api_lnurl_response(
|
|
request: Request, unique_hash: str
|
|
) -> LnurlWithdrawResponse | LnurlErrorResponse:
|
|
link = await get_withdraw_link_by_hash(unique_hash)
|
|
|
|
if not link:
|
|
return LnurlErrorResponse(reason="Withdraw link does not exist.")
|
|
|
|
if not link.enabled:
|
|
return LnurlErrorResponse(reason="Withdraw link is disabled.")
|
|
|
|
if link.is_spent:
|
|
return LnurlErrorResponse(reason="Withdraw is spent.")
|
|
|
|
if link.is_unique:
|
|
return LnurlErrorResponse(reason="This link requires an id_unique_hash.")
|
|
|
|
url = str(
|
|
request.url_for("withdraw.api_lnurl_callback", unique_hash=link.unique_hash)
|
|
)
|
|
|
|
callback_url = parse_obj_as(CallbackUrl, url)
|
|
return LnurlWithdrawResponse(
|
|
callback=callback_url,
|
|
k1=link.k1,
|
|
minWithdrawable=MilliSatoshi(link.min_withdrawable * 1000),
|
|
maxWithdrawable=MilliSatoshi(link.max_withdrawable * 1000),
|
|
defaultDescription=link.title,
|
|
)
|
|
|
|
|
|
@withdraw_ext_lnurl.get(
|
|
"/cb/{unique_hash}",
|
|
name="withdraw.api_lnurl_callback",
|
|
summary="lnurl withdraw callback",
|
|
description="""
|
|
This endpoints allows you to put unique_hash, k1
|
|
and a payment_request to get your payment_request paid.
|
|
""",
|
|
response_class=JSONResponse,
|
|
response_description="JSON with status",
|
|
responses={
|
|
200: {"description": "status: OK"},
|
|
400: {"description": "k1 is wrong or link open time or withdraw not working."},
|
|
404: {"description": "withdraw link not found."},
|
|
405: {"description": "withdraw link is spent."},
|
|
},
|
|
)
|
|
async def api_lnurl_callback(
|
|
unique_hash: str,
|
|
k1: str,
|
|
pr: str,
|
|
id_unique_hash: str | None = None,
|
|
) -> LnurlErrorResponse | LnurlSuccessResponse:
|
|
link = await get_withdraw_link_by_hash(unique_hash)
|
|
if not link:
|
|
return LnurlErrorResponse(reason="withdraw link not found.")
|
|
|
|
if not link.enabled:
|
|
return LnurlErrorResponse(reason="Withdraw link is disabled.")
|
|
|
|
bolt11 = decode_bolt11(pr)
|
|
if not bolt11.amount_msat:
|
|
return LnurlErrorResponse(reason="0 amount invoices are not supported.")
|
|
|
|
if (
|
|
link.min_withdrawable * 1000 > bolt11.amount_msat
|
|
or bolt11.amount_msat > link.max_withdrawable * 1000
|
|
):
|
|
return LnurlErrorResponse(reason="Amount not within limits.")
|
|
|
|
if link.is_spent:
|
|
return LnurlErrorResponse(reason="withdraw is spent.")
|
|
|
|
if link.k1 != k1:
|
|
return LnurlErrorResponse(reason="k1 is wrong.")
|
|
|
|
now = int(datetime.now().timestamp())
|
|
|
|
if now < link.open_time + link.wait_time:
|
|
return LnurlErrorResponse(
|
|
reason=f"Wait {link.open_time + link.wait_time - now} seconds."
|
|
)
|
|
|
|
if not id_unique_hash and link.is_unique:
|
|
return LnurlErrorResponse(reason="id_unique_hash is required for this link.")
|
|
|
|
if id_unique_hash:
|
|
if check_unique_link(link, id_unique_hash):
|
|
await remove_unique_withdraw_link(link, id_unique_hash)
|
|
else:
|
|
return LnurlErrorResponse(reason="id_unique_hash not found.")
|
|
|
|
# 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.
|
|
try:
|
|
await create_hash_check(id_unique_hash or unique_hash, k1)
|
|
except Exception:
|
|
return LnurlErrorResponse(reason="LNURL already being processed.")
|
|
|
|
try:
|
|
payment = await pay_invoice(
|
|
wallet_id=link.wallet,
|
|
payment_request=pr,
|
|
max_sat=link.max_withdrawable,
|
|
extra={"tag": "withdraw", "withdrawal_link_id": link.id},
|
|
)
|
|
await increment_withdraw_link(link)
|
|
# If the payment succeeds, delete the record with the unique_hash.
|
|
# TODO: we delete this now: "If it has unique_hash, do not delete to prevent
|
|
# the same LNURL from being processed twice."
|
|
await delete_hash_check(id_unique_hash or unique_hash)
|
|
|
|
if link.webhook_url:
|
|
await dispatch_webhook(link, payment, pr)
|
|
return LnurlSuccessResponse()
|
|
except Exception as exc:
|
|
# If payment fails, delete the hash stored so another attempt can be made.
|
|
await delete_hash_check(id_unique_hash or unique_hash)
|
|
return LnurlErrorResponse(reason=f"withdraw not working. {exc!s}")
|
|
|
|
|
|
def check_unique_link(link: WithdrawLink, unique_hash: str) -> bool:
|
|
return any(
|
|
unique_hash == shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
|
|
for x in link.usescsv.split(",")
|
|
)
|
|
|
|
|
|
async def dispatch_webhook(
|
|
link: WithdrawLink, payment: Payment, payment_request: str
|
|
) -> None:
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
r: httpx.Response = await client.post(
|
|
link.webhook_url,
|
|
json={
|
|
"payment_hash": payment.payment_hash,
|
|
"payment_request": payment_request,
|
|
"lnurlw": link.id,
|
|
"body": json.loads(link.webhook_body) if link.webhook_body else "",
|
|
},
|
|
headers=(
|
|
json.loads(link.webhook_headers) if link.webhook_headers else None
|
|
),
|
|
timeout=40,
|
|
)
|
|
payment.extra["wh_success"] = r.is_success
|
|
payment.extra["wh_message"] = r.reason_phrase
|
|
payment.extra["wh_response"] = r.text
|
|
await update_payment(payment)
|
|
except Exception as exc:
|
|
# webhook fails shouldn't cause the lnurlw to fail
|
|
# since invoice is already paid
|
|
logger.error(f"Caught exception when dispatching webhook url: {exc!s}")
|
|
payment.extra["wh_success"] = False
|
|
payment.extra["wh_message"] = str(exc)
|
|
await update_payment(payment)
|
|
|
|
|
|
# FOR LNURLs WHICH ARE UNIQUE
|
|
@withdraw_ext_lnurl.get(
|
|
"/{unique_hash}/{id_unique_hash}",
|
|
response_class=JSONResponse,
|
|
name="withdraw.api_lnurl_multi_response",
|
|
)
|
|
async def api_lnurl_multi_response(
|
|
request: Request, unique_hash: str, id_unique_hash: str
|
|
) -> LnurlWithdrawResponse | LnurlErrorResponse:
|
|
link = await get_withdraw_link_by_hash(unique_hash)
|
|
|
|
if not link:
|
|
return LnurlErrorResponse(reason="Withdraw link does not exist.")
|
|
|
|
if not link.enabled:
|
|
return LnurlErrorResponse(reason="Withdraw link is disabled.")
|
|
|
|
if link.is_spent:
|
|
return LnurlErrorResponse(reason="Withdraw is spent.")
|
|
|
|
if not check_unique_link(link, id_unique_hash):
|
|
return LnurlErrorResponse(reason="id_unique_hash not found for this link.")
|
|
|
|
url = 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,
|
|
)
|