From 3d1fb02e5c076432335efb698d47e669ac011825 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Mon, 25 Aug 2025 09:43:33 +0200
Subject: [PATCH 01/10] feat: introduce withdraw secrets
- refactor lnurl endpoints
- cleanup api endpoints (post|update)
---
crud.py | 120 ++++++--------------------------
migrations.py | 4 ++
models.py | 111 +++++++++++++++++++++---------
views.py | 2 +-
views_api.py | 107 ++++++-----------------------
views_lnurl.py | 182 +++++++++++++++----------------------------------
6 files changed, 181 insertions(+), 345 deletions(-)
diff --git a/crud.py b/crud.py
index 7d79ccd..a294112 100644
--- a/crud.py
+++ b/crud.py
@@ -1,10 +1,6 @@
-from datetime import datetime
-
-import shortuuid
from lnbits.db import Database
-from lnbits.helpers import urlsafe_short_hash
-from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
+from .models import CreateWithdrawData, PaginatedWithdraws, WithdrawLink, WithdrawSecret
db = Database("ext_withdraw")
@@ -12,56 +8,46 @@
async def create_withdraw_link(
data: CreateWithdrawData, wallet_id: str
) -> WithdrawLink:
- link_id = urlsafe_short_hash()[:22]
- available_links = ",".join([str(i) for i in range(data.uses)])
withdraw_link = WithdrawLink(
- id=link_id,
- wallet=wallet_id,
- unique_hash=urlsafe_short_hash(),
- k1=urlsafe_short_hash(),
- created_at=datetime.now(),
- open_time=int(datetime.now().timestamp()) + data.wait_time,
title=data.title,
+ wallet=data.wallet or wallet_id,
min_withdrawable=data.min_withdrawable,
max_withdrawable=data.max_withdrawable,
- uses=data.uses,
wait_time=data.wait_time,
- is_unique=data.is_unique,
- usescsv=available_links,
+ is_static=data.is_static,
webhook_url=data.webhook_url,
webhook_headers=data.webhook_headers,
webhook_body=data.webhook_body,
custom_url=data.custom_url,
- number=0,
)
+ secrets = []
+ for _ in range(data.uses):
+ secrets.append(
+ WithdrawSecret(
+ withdraw_id=withdraw_link.id,
+ amount=withdraw_link.max_withdrawable,
+ )
+ )
+ withdraw_link.secrets.total = data.uses
+ withdraw_link.secrets.items = secrets
await db.insert("withdraw.withdraw_link", withdraw_link)
return withdraw_link
-async def get_withdraw_link(link_id: str, num=0) -> WithdrawLink | None:
- link = await db.fetchone(
+async def get_withdraw_link(link_id: str) -> WithdrawLink | None:
+ return await db.fetchone(
"SELECT * FROM withdraw.withdraw_link WHERE id = :id",
{"id": link_id},
WithdrawLink,
)
- if not link:
- return None
-
- link.number = num
- return link
-async def get_withdraw_link_by_hash(unique_hash: str, num=0) -> WithdrawLink | None:
- link = await db.fetchone(
- "SELECT * FROM withdraw.withdraw_link WHERE unique_hash = :hash",
- {"hash": unique_hash},
+async def get_withdraw_link_by_k1(k1: str) -> WithdrawLink | None:
+ return await db.fetchone(
+ "SELECT * FROM withdraw.withdraw_link WHERE secrets LIKE '%:k1%'",
+ {"k1": k1},
WithdrawLink,
)
- if not link:
- return None
-
- link.number = num
- return link
async def get_withdraw_links(
@@ -96,22 +82,6 @@ async def get_withdraw_links(
return PaginatedWithdraws(data=links, total=int(result2.total))
-async def remove_unique_withdraw_link(link: WithdrawLink, unique_hash: str) -> None:
- unique_links = [
- x.strip()
- for x in link.usescsv.split(",")
- if unique_hash != shortuuid.uuid(name=link.id + link.unique_hash + x.strip())
- ]
- link.usescsv = ",".join(unique_links)
- await update_withdraw_link(link)
-
-
-async def increment_withdraw_link(link: WithdrawLink) -> None:
- link.used = link.used + 1
- link.open_time = int(datetime.now().timestamp()) + link.wait_time
- await update_withdraw_link(link)
-
-
async def update_withdraw_link(link: WithdrawLink) -> WithdrawLink:
await db.update("withdraw.withdraw_link", link)
return link
@@ -121,55 +91,3 @@ async def delete_withdraw_link(link_id: str) -> None:
await db.execute(
"DELETE FROM withdraw.withdraw_link WHERE id = :id", {"id": link_id}
)
-
-
-def chunks(lst, n):
- for i in range(0, len(lst), n):
- yield lst[i : i + n]
-
-
-async def create_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
- await db.execute(
- """
- INSERT INTO withdraw.hash_check (id, lnurl_id)
- VALUES (:id, :lnurl_id)
- """,
- {"id": the_hash, "lnurl_id": lnurl_id},
- )
- hash_check = await get_hash_check(the_hash, lnurl_id)
- return hash_check
-
-
-async def get_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
-
- hash_check = await db.fetchone(
- """
- SELECT id as hash, lnurl_id as lnurl
- FROM withdraw.hash_check WHERE id = :id
- """,
- {"id": the_hash},
- HashCheck,
- )
- hash_check_lnurl = await db.fetchone(
- """
- SELECT id as hash, lnurl_id as lnurl
- FROM withdraw.hash_check WHERE lnurl_id = :id
- """,
- {"id": lnurl_id},
- HashCheck,
- )
- if not hash_check_lnurl:
- await create_hash_check(the_hash, lnurl_id)
- return HashCheck(lnurl=True, hash=False)
- else:
- if not hash_check:
- await create_hash_check(the_hash, lnurl_id)
- return HashCheck(lnurl=True, hash=False)
- else:
- return HashCheck(lnurl=True, hash=True)
-
-
-async def delete_hash_check(the_hash: str) -> None:
- await db.execute(
- "DELETE FROM withdraw.hash_check WHERE id = :hash", {"hash": the_hash}
- )
diff --git a/migrations.py b/migrations.py
index 754a57f..4873fec 100644
--- a/migrations.py
+++ b/migrations.py
@@ -139,3 +139,7 @@ async def m007_add_created_at_timestamp(db):
"ALTER TABLE withdraw.withdraw_link "
f"ADD COLUMN created_at TIMESTAMP DEFAULT {db.timestamp_column_default}"
)
+
+
+async def m008_add_secrets(db):
+ await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN secrets TEXT;")
diff --git a/models.py b/models.py
index b476dce..268ec3f 100644
--- a/models.py
+++ b/models.py
@@ -1,51 +1,98 @@
-from datetime import datetime
+import json
+from datetime import datetime, timezone
from fastapi import Query
-from pydantic import BaseModel, Field
+from lnbits.helpers import urlsafe_short_hash
+from pydantic import BaseModel, Field, validator
class CreateWithdrawData(BaseModel):
title: str = Query(...)
min_withdrawable: int = Query(..., ge=1)
max_withdrawable: int = Query(..., ge=1)
- uses: int = Query(..., ge=1)
+ uses: int = Query(..., ge=1, le=250)
wait_time: int = Query(..., ge=1)
- is_unique: bool
- webhook_url: str = Query(None)
- webhook_headers: str = Query(None)
- webhook_body: str = Query(None)
- custom_url: str = Query(None)
+ is_static: bool = Query(True)
+ wallet: str | None = Query(None)
+ webhook_url: str | None = Query(None)
+ webhook_headers: str | None = Query(None)
+ webhook_body: str | None = Query(None)
+ custom_url: str | None = Query(None)
+ @validator("max_withdrawable")
+ def check_max_withdrawable(self, v, values):
+ if "min_withdrawable" in values and v < values["min_withdrawable"]:
+ raise ValueError("max_withdrawable must be at least min_withdrawable")
+ return v
-class WithdrawLink(BaseModel):
- id: str
- wallet: str = Query(None)
- title: str = Query(None)
- min_withdrawable: int = Query(0)
- max_withdrawable: int = Query(0)
- uses: int = Query(0)
- wait_time: int = Query(0)
- is_unique: bool = Query(False)
- unique_hash: str = Query(0)
- k1: str = Query(None)
- open_time: int = Query(0)
- used: int = Query(0)
- usescsv: str = Query(None)
- number: int = Field(default=0, no_database=True)
- webhook_url: str = Query(None)
- webhook_headers: str = Query(None)
- webhook_body: str = Query(None)
- custom_url: str = Query(None)
- created_at: datetime
+ @validator("webhook_body")
+ def check_webhook_body(self, v):
+ if v:
+ try:
+ json.loads(v)
+ except Exception as exc:
+ raise ValueError("webhook_body must be valid JSON") from exc
+ return v
+
+ @validator("webhook_headers")
+ def check_headers_json(self, v):
+ if v:
+ try:
+ json.loads(v)
+ except Exception as exc:
+ raise ValueError("webhook_headers must be valid JSON") from exc
+ return v
+
+
+class WithdrawSecret(BaseModel):
+ k1: str = Field(default_factory=urlsafe_short_hash)
+ withdraw_id: str
+ amount: int | None = None
+ used: bool = False
+ used_at: int | None = None
+
+
+class WithdrawSecrets(BaseModel):
+ total: int = 0
+ used: int = 0
+ items: list[WithdrawSecret] = []
@property
def is_spent(self) -> bool:
- return self.used >= self.uses
+ return self.used >= self.total
+ @property
+ def next_secret(self) -> WithdrawSecret | None:
+ return next((item for item in self.items if not item.used), None)
+
+ def get_secret(self, k1: str) -> WithdrawSecret | None:
+ return next((item for item in self.items if item.k1 == k1), None)
+
+ def use_secret(self, k1: str) -> WithdrawSecret | None:
+ for item in self.items:
+ if item.k1 == k1 and not item.used:
+ item.used = True
+ item.used_at = int(datetime.now().timestamp())
+ self.used += 1
+ return item
+ return None
-class HashCheck(BaseModel):
- hash: bool
- lnurl: bool
+
+class WithdrawLink(BaseModel):
+ id: str = Field(default_factory=lambda: urlsafe_short_hash()[:22])
+ wallet: str
+ title: str
+ min_withdrawable: int
+ max_withdrawable: int
+ wait_time: int
+ is_static: bool
+ webhook_url: str | None = None
+ webhook_headers: str | None = None
+ webhook_body: str | None = None
+ custom_url: str | None = None
+ open_time: int = Field(default_factory=lambda: int(datetime.now().timestamp()))
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ secrets: WithdrawSecrets = WithdrawSecrets()
class PaginatedWithdraws(BaseModel):
diff --git a/views.py b/views.py
index ba48a5d..361324a 100644
--- a/views.py
+++ b/views.py
@@ -26,7 +26,7 @@ async def index(request: Request, user: User = Depends(check_user_exists)):
@withdraw_ext_generic.get("/{link_id}", response_class=HTMLResponse)
async def display(request: Request, link_id):
- link = await get_withdraw_link(link_id, 0)
+ link = await get_withdraw_link(link_id)
if not link:
raise HTTPException(
diff --git a/views_api.py b/views_api.py
index f93d8f1..aa8785e 100644
--- a/views_api.py
+++ b/views_api.py
@@ -1,4 +1,3 @@
-import json
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -9,17 +8,16 @@
from .crud import (
create_withdraw_link,
delete_withdraw_link,
- get_hash_check,
get_withdraw_link,
get_withdraw_links,
update_withdraw_link,
)
-from .models import CreateWithdrawData, HashCheck, PaginatedWithdraws, WithdrawLink
+from .models import CreateWithdrawData, PaginatedWithdraws, WithdrawLink
withdraw_ext_api = APIRouter(prefix="/api/v1")
-@withdraw_ext_api.get("/links", status_code=HTTPStatus.OK)
+@withdraw_ext_api.get("/links")
async def api_links(
key_info: WalletTypeInfo = Depends(require_invoice_key),
all_wallets: bool = Query(False),
@@ -35,11 +33,11 @@ async def api_links(
return await get_withdraw_links(wallet_ids, limit, offset)
-@withdraw_ext_api.get("/links/{link_id}", status_code=HTTPStatus.OK)
+@withdraw_ext_api.get("/links/{link_id}")
async def api_link_retrieve(
link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
) -> WithdrawLink:
- link = await get_withdraw_link(link_id, 0)
+ link = await get_withdraw_link(link_id)
if not link:
raise HTTPException(
@@ -54,85 +52,34 @@ async def api_link_retrieve(
@withdraw_ext_api.post("/links", status_code=HTTPStatus.CREATED)
-@withdraw_ext_api.put("/links/{link_id}")
-async def api_link_create_or_update(
+async def api_link_create(
data: CreateWithdrawData,
- link_id: str | None = None,
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> WithdrawLink:
- if data.uses > 250:
- raise HTTPException(detail="250 uses max.", status_code=HTTPStatus.BAD_REQUEST)
+ link = await create_withdraw_link(data, key_info.wallet.id)
+ return link
- if data.min_withdrawable < 1:
+
+@withdraw_ext_api.put("/links/{link_id}")
+async def api_link_update(
+ link_id: str,
+ data: CreateWithdrawData,
+ key_info: WalletTypeInfo = Depends(require_admin_key),
+) -> WithdrawLink:
+ link = await get_withdraw_link(link_id)
+ if not link:
raise HTTPException(
- detail="Min must be more than 1.", status_code=HTTPStatus.BAD_REQUEST
+ detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
)
-
- if data.max_withdrawable < data.min_withdrawable:
+ if link.wallet != key_info.wallet.id:
raise HTTPException(
- detail="`max_withdrawable` needs to be at least `min_withdrawable`.",
- status_code=HTTPStatus.BAD_REQUEST,
+ detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
)
- if data.webhook_body:
- try:
- json.loads(data.webhook_body)
- except Exception as exc:
- raise HTTPException(
- detail="`webhook_body` can not parse JSON.",
- status_code=HTTPStatus.BAD_REQUEST,
- ) from exc
-
- if data.webhook_headers:
- try:
- json.loads(data.webhook_headers)
- except Exception as exc:
- raise HTTPException(
- detail="`webhook_headers` can not parse JSON.",
- status_code=HTTPStatus.BAD_REQUEST,
- ) from exc
-
- if link_id:
- link = await get_withdraw_link(link_id, 0)
- if not link:
- raise HTTPException(
- detail="Withdraw link does not exist.", status_code=HTTPStatus.NOT_FOUND
- )
- if link.wallet != key_info.wallet.id:
- raise HTTPException(
- detail="Not your withdraw link.", status_code=HTTPStatus.FORBIDDEN
- )
-
- if link.uses > data.uses:
- if data.uses - link.used <= 0:
- raise HTTPException(
- detail="Cannot reduce uses below current used.",
- status_code=HTTPStatus.BAD_REQUEST,
- )
- numbers = link.usescsv.split(",")
- link.usescsv = ",".join(numbers[: data.uses - link.used])
-
- if link.uses < data.uses:
- numbers = link.usescsv.split(",")
- if numbers[-1] == "":
- current_number = int(link.uses)
- numbers[-1] = str(link.uses)
- else:
- current_number = int(numbers[-1])
- while len(numbers) < (data.uses - link.used):
- current_number += 1
- numbers.append(str(current_number))
- link.usescsv = ",".join(numbers)
-
- for k, v in data.dict().items():
- if v is not None:
- setattr(link, k, v)
-
- link = await update_withdraw_link(link)
- else:
- link = await create_withdraw_link(wallet_id=key_info.wallet.id, data=data)
+ for k, v in data.dict().items():
+ setattr(link, k, v)
- return link
+ return await update_withdraw_link(link)
@withdraw_ext_api.delete("/links/{link_id}")
@@ -153,13 +100,3 @@ async def api_link_delete(
await delete_withdraw_link(link_id)
return SimpleStatus(success=True, message="Withdraw link deleted.")
-
-
-@withdraw_ext_api.get(
- "/links/{the_hash}/{lnurl_id}",
- status_code=HTTPStatus.OK,
- dependencies=[Depends(require_invoice_key)],
-)
-async def api_hash_retrieve(the_hash, lnurl_id) -> HashCheck:
- hash_check = await get_hash_check(the_hash, lnurl_id)
- return hash_check
diff --git a/views_lnurl.py b/views_lnurl.py
index d62b21d..a487fc1 100644
--- a/views_lnurl.py
+++ b/views_lnurl.py
@@ -2,14 +2,14 @@
from datetime import datetime
import httpx
-import shortuuid
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 lnbits.exceptions import PaymentError
from lnurl import (
CallbackUrl,
+ InvalidLnurl,
LnurlErrorResponse,
LnurlSuccessResponse,
LnurlWithdrawResponse,
@@ -18,140 +18,102 @@
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 .crud import get_withdraw_link, get_withdraw_link_by_k1, update_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",
-)
+@withdraw_ext_lnurl.get("/{id_or_k1}")
async def api_lnurl_response(
- request: Request, unique_hash: str
+ request: Request, id_or_k1: str
) -> LnurlWithdrawResponse | LnurlErrorResponse:
- link = await get_withdraw_link_by_hash(unique_hash)
+ link = await get_withdraw_link(id_or_k1)
- if not link:
- return LnurlErrorResponse(reason="Withdraw link does not exist.")
-
- 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)
- )
+ # static links are identified by their id
+ if link:
+ if not link.is_static:
+ return LnurlErrorResponse(
+ reason="Withdraw link is not static. Only use 'id' for static links."
+ )
+ secret = link.secrets.next_secret
+ if not secret:
+ return LnurlErrorResponse(reason="Withdraw is spent.")
+
+ # non-static links are identified by their k1
+ else:
+ link = await get_withdraw_link_by_k1(id_or_k1)
+ if not link:
+ return LnurlErrorResponse(reason="Withdraw link does not exist.")
+ secret = link.secrets.get_secret(id_or_k1)
+ if not secret:
+ return LnurlErrorResponse(reason="Invalid k1.")
+ if secret.used:
+ return LnurlErrorResponse(reason="Withdraw is spent.")
+
+ url = request.url_for("withdraw.lnurl_callback")
+ try:
+ callback_url = parse_obj_as(CallbackUrl, str(url))
+ except InvalidLnurl:
+ return LnurlErrorResponse(reason=f"Invalid callback URL. {url!s}")
- callback_url = parse_obj_as(CallbackUrl, url)
return LnurlWithdrawResponse(
callback=callback_url,
- k1=link.k1,
+ k1=secret.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."},
- },
-)
+@withdraw_ext_lnurl.get("/cb", name="withdraw.lnurl_callback")
async def api_lnurl_callback(
- unique_hash: str,
- k1: str,
- pr: str,
- id_unique_hash: str | None = None,
+ k1: str, pr: str
) -> LnurlErrorResponse | LnurlSuccessResponse:
- link = await get_withdraw_link_by_hash(unique_hash)
+ link = await get_withdraw_link_by_k1(k1)
if not link:
- return LnurlErrorResponse(reason="withdraw link not found.")
-
- if link.is_spent:
- return LnurlErrorResponse(reason="withdraw is spent.")
-
- if link.k1 != k1:
- return LnurlErrorResponse(reason="k1 is wrong.")
+ return LnurlErrorResponse(reason="Invalid k1.")
now = int(datetime.now().timestamp())
-
if now < link.open_time:
return LnurlErrorResponse(
reason=f"wait link open_time {link.open_time - now} seconds."
)
- if not id_unique_hash and link.is_unique:
- return LnurlErrorResponse(reason="id_unique_hash is required for this link.")
+ secret = link.secrets.get_secret(k1)
+ if not secret:
+ return LnurlErrorResponse(reason="Invalid k1.")
- 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.")
+ if secret.used:
+ return LnurlErrorResponse(reason="Withdraw is spent.")
- # 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.")
+ # IMPORTANT: update the link in the db before paying the invoice
+ # so that concurrent requests can't use the same secret
+ link.secrets.use_secret(k1)
+ await update_withdraw_link(link)
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},
+ extra={"tag": "withdraw", "withdraw_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(",")
- )
+ except PaymentError as exc:
+ return LnurlErrorResponse(reason=f"Payment error: {exc.message}")
+
+ if link.webhook_url:
+ await dispatch_webhook(link, payment, pr)
+
+ return LnurlSuccessResponse()
async def dispatch_webhook(
link: WithdrawLink, payment: Payment, payment_request: str
) -> None:
+ if not link.webhook_url:
+ return
async with httpx.AsyncClient() as client:
try:
r: httpx.Response = await client.post(
@@ -178,35 +140,3 @@ async def dispatch_webhook(
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 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,
- )
From e8caea93057faed170fb01fe1ea834d43c498089 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Mon, 25 Aug 2025 14:01:35 +0200
Subject: [PATCH 02/10] remove custom qr and helper
---
helpers.py | 28 ------
templates/withdraw/print_qr_custom.html | 111 ------------------------
2 files changed, 139 deletions(-)
delete mode 100644 helpers.py
delete mode 100644 templates/withdraw/print_qr_custom.html
diff --git a/helpers.py b/helpers.py
deleted file mode 100644
index 51eb948..0000000
--- a/helpers.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from fastapi import Request
-from lnurl import Lnurl
-from lnurl import encode as lnurl_encode
-from shortuuid import uuid
-
-from .models import WithdrawLink
-
-
-def create_lnurl(link: WithdrawLink, req: Request) -> Lnurl:
- if link.is_unique:
- usescssv = link.usescsv.split(",")
- tohash = link.id + link.unique_hash + usescssv[link.number]
- multihash = uuid(name=tohash)
- url = req.url_for(
- "withdraw.api_lnurl_multi_response",
- unique_hash=link.unique_hash,
- id_unique_hash=multihash,
- )
- else:
- url = req.url_for("withdraw.api_lnurl_response", unique_hash=link.unique_hash)
-
- try:
- return lnurl_encode(str(url))
- except Exception as e:
- raise ValueError(
- f"Error creating LNURL with url: `{url!s}`, "
- "check your webserver proxy configuration."
- ) from e
diff --git a/templates/withdraw/print_qr_custom.html b/templates/withdraw/print_qr_custom.html
deleted file mode 100644
index 8e34097..0000000
--- a/templates/withdraw/print_qr_custom.html
+++ /dev/null
@@ -1,111 +0,0 @@
-{% extends "print.html" %} {% block page %}
-
-
-
- {% for page in link %}
-
- {% for one in page %}
-
-

-
{{ amt }} sats
-
-
-
-
- {% endfor %}
-
- {% endfor %}
-
-
-{% endblock %} {% block styles %}
-
-{% endblock %} {% block scripts %}
-
-{% endblock %}
From 091f11870414bf8cd7c28f41816c3259e95f04ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Mon, 25 Aug 2025 14:02:00 +0200
Subject: [PATCH 03/10] temp fix
---
models.py | 4 +--
views.py | 105 ++++++++++++++++++++++--------------------------------
2 files changed, 45 insertions(+), 64 deletions(-)
diff --git a/models.py b/models.py
index 268ec3f..3c959c8 100644
--- a/models.py
+++ b/models.py
@@ -9,7 +9,7 @@
class CreateWithdrawData(BaseModel):
title: str = Query(...)
min_withdrawable: int = Query(..., ge=1)
- max_withdrawable: int = Query(..., ge=1)
+ max_withdrawable: int = Query(...)
uses: int = Query(..., ge=1, le=250)
wait_time: int = Query(..., ge=1)
is_static: bool = Query(True)
@@ -20,7 +20,7 @@ class CreateWithdrawData(BaseModel):
custom_url: str | None = Query(None)
@validator("max_withdrawable")
- def check_max_withdrawable(self, v, values):
+ def check_max_withdrawable(self, v: int, values) -> int:
if "min_withdrawable" in values and v < values["min_withdrawable"]:
raise ValueError("max_withdrawable must be at least min_withdrawable")
return v
diff --git a/views.py b/views.py
index 361324a..05e625d 100644
--- a/views.py
+++ b/views.py
@@ -1,4 +1,5 @@
import io
+from datetime import datetime, timezone
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -6,9 +7,10 @@
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
+from lnurl import Lnurl
+from pydantic import parse_obj_as
-from .crud import chunks, get_withdraw_link
-from .helpers import create_lnurl
+from .crud import get_withdraw_link
withdraw_ext_generic = APIRouter()
@@ -33,12 +35,18 @@ async def display(request: Request, link_id):
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
+ if link.open_time and link.open_time > datetime.now(timezone.utc).timestamp():
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN,
+ detail="Withdraw link is not yet active.",
+ )
+
return withdraw_renderer().TemplateResponse(
"withdraw/display.html",
{
"request": request,
- "spent": link.is_spent,
- "unique_hash": link.unique_hash,
+ "spent": link.secrets.is_spent,
+ "unique_hash": link.secrets.next_secret,
},
)
@@ -51,78 +59,51 @@ async def print_qr(request: Request, link_id):
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
- if link.uses == 0:
-
- return withdraw_renderer().TemplateResponse(
- "withdraw/print_qr.html",
- {"request": request, "link": link.json(), "unique": False},
- )
- links = []
- count = 0
-
- for _ in link.usescsv.split(","):
- linkk = await get_withdraw_link(link_id, count)
- if not linkk:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
- )
- try:
- lnurl = create_lnurl(linkk, request)
- except ValueError as exc:
- raise HTTPException(
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- detail=str(exc),
- ) from exc
- links.append(str(lnurl.bech32))
- count = count + 1
- page_link = list(chunks(links, 2))
- linked = list(chunks(page_link, 5))
-
- if link.custom_url:
- return withdraw_renderer().TemplateResponse(
- "withdraw/print_qr_custom.html",
- {
- "request": request,
- "link": page_link,
- "unique": True,
- "custom_url": link.custom_url,
- "amt": link.max_withdrawable,
- },
- )
+ # if link.uses == 0:
+
+ # return withdraw_renderer().TemplateResponse(
+ # "withdraw/print_qr.html",
+ # {"request": request, "link": link.json(), "unique": False},
+ # )
+ # links = []
+ # count = 0
+
+ # for _ in link.usescsv.split(","):
+ # linkk = await get_withdraw_link(link_id, count)
+ # if not linkk:
+ # raise HTTPException(
+ # HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
+ # )
+ # try:
+ # lnurl = create_lnurl(linkk, request)
+ # except ValueError as exc:
+ # raise HTTPException(
+ # status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ # detail=str(exc),
+ # ) from exc
+ # links.append(str(lnurl.bech32))
+ # count = count + 1
+ # page_link = list(chunks(links, 2))
+ # linked = list(chunks(page_link, 5))
return withdraw_renderer().TemplateResponse(
- "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True}
+ "withdraw/print_qr.html", {"request": request, "link": [], "unique": True}
)
@withdraw_ext_generic.get("/csv/{link_id}", response_class=HTMLResponse)
-async def csv(request: Request, link_id):
+async def csv(req: Request, link_id):
link = await get_withdraw_link(link_id)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
- if link.uses == 0:
- raise HTTPException(
- status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw is spent."
- )
-
buffer = io.StringIO()
count = 0
- for _ in link.usescsv.split(","):
- linkk = await get_withdraw_link(link_id, count)
- if not linkk:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
- )
- try:
- lnurl = create_lnurl(linkk, request)
- except ValueError as exc:
- raise HTTPException(
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- detail=str(exc),
- ) from exc
+ for _ in link.secrets.items:
+ url = req.url_for("withdraw.lnurl_callback", id_or_secret=link_id)
+ lnurl = parse_obj_as(Lnurl, str(url))
buffer.write(f"{lnurl.bech32!s}\n")
count += 1
From 21bce267f5db6810263f699a69e5d4be97de488e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Tue, 26 Aug 2025 12:07:59 +0200
Subject: [PATCH 04/10] fixup print and csv
---
crud.py | 7 +--
migrations.py | 8 ++-
models.py | 14 ++---
pyproject.toml | 1 +
static/js/index.js | 27 +++++++---
templates/withdraw/index.html | 92 ++++++++++++++++++++------------
templates/withdraw/print_qr.html | 50 +++++++++++------
views.py | 75 +++++++++++++-------------
views_api.py | 2 +
views_lnurl.py | 90 ++++++++++++++++---------------
10 files changed, 218 insertions(+), 148 deletions(-)
diff --git a/crud.py b/crud.py
index a294112..f898267 100644
--- a/crud.py
+++ b/crud.py
@@ -10,11 +10,12 @@ async def create_withdraw_link(
) -> WithdrawLink:
withdraw_link = WithdrawLink(
title=data.title,
- wallet=data.wallet or wallet_id,
+ wallet=wallet_id,
min_withdrawable=data.min_withdrawable,
max_withdrawable=data.max_withdrawable,
wait_time=data.wait_time,
is_static=data.is_static,
+ is_public=data.is_public,
webhook_url=data.webhook_url,
webhook_headers=data.webhook_headers,
webhook_body=data.webhook_body,
@@ -44,8 +45,8 @@ async def get_withdraw_link(link_id: str) -> WithdrawLink | None:
async def get_withdraw_link_by_k1(k1: str) -> WithdrawLink | None:
return await db.fetchone(
- "SELECT * FROM withdraw.withdraw_link WHERE secrets LIKE '%:k1%'",
- {"k1": k1},
+ "SELECT * FROM withdraw.withdraw_link WHERE secrets LIKE :k1",
+ {"k1": f"%{k1}%"},
WithdrawLink,
)
diff --git a/migrations.py b/migrations.py
index 4873fec..6a5b307 100644
--- a/migrations.py
+++ b/migrations.py
@@ -141,5 +141,11 @@ async def m007_add_created_at_timestamp(db):
)
-async def m008_add_secrets(db):
+async def m008_add_secrets_static_public(db):
await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN secrets TEXT;")
+ await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN is_static BOOLEAN;")
+ await db.execute("UPDATE withdraw.withdraw_link SET is_static = NOT is_unique;")
+ await db.execute(
+ "ALTER TABLE withdraw.withdraw_link ADD COLUMN is_public DEFAULT TRUE;"
+ )
+ # await db.execute("DROP TABLE withdraw.hash_check;")
diff --git a/models.py b/models.py
index 3c959c8..3642b26 100644
--- a/models.py
+++ b/models.py
@@ -9,24 +9,24 @@
class CreateWithdrawData(BaseModel):
title: str = Query(...)
min_withdrawable: int = Query(..., ge=1)
- max_withdrawable: int = Query(...)
+ max_withdrawable: int = Query(..., ge=1)
uses: int = Query(..., ge=1, le=250)
wait_time: int = Query(..., ge=1)
is_static: bool = Query(True)
- wallet: str | None = Query(None)
+ is_public: bool = Query(True)
webhook_url: str | None = Query(None)
webhook_headers: str | None = Query(None)
webhook_body: str | None = Query(None)
custom_url: str | None = Query(None)
@validator("max_withdrawable")
- def check_max_withdrawable(self, v: int, values) -> int:
+ def check_max_withdrawable(cls, v: int, values) -> int:
if "min_withdrawable" in values and v < values["min_withdrawable"]:
raise ValueError("max_withdrawable must be at least min_withdrawable")
return v
@validator("webhook_body")
- def check_webhook_body(self, v):
+ def check_webhook_body(cls, v):
if v:
try:
json.loads(v)
@@ -35,7 +35,7 @@ def check_webhook_body(self, v):
return v
@validator("webhook_headers")
- def check_headers_json(self, v):
+ def check_headers_json(cls, v):
if v:
try:
json.loads(v)
@@ -47,9 +47,10 @@ def check_headers_json(self, v):
class WithdrawSecret(BaseModel):
k1: str = Field(default_factory=urlsafe_short_hash)
withdraw_id: str
- amount: int | None = None
+ amount: int
used: bool = False
used_at: int | None = None
+ payment_hash_melt: str | None = None
class WithdrawSecrets(BaseModel):
@@ -86,6 +87,7 @@ class WithdrawLink(BaseModel):
max_withdrawable: int
wait_time: int
is_static: bool
+ is_public: bool
webhook_url: str | None = None
webhook_headers: str | None = None
webhook_body: str | None = None
diff --git a/pyproject.toml b/pyproject.toml
index 8f281dd..3d53dba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -68,6 +68,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
+ "validator",
]
# Ignore unused imports in __init__.py files.
diff --git a/static/js/index.js b/static/js/index.js
index 9a8a9c1..0599289 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -68,7 +68,8 @@ window.app = Vue.createApp({
secondMultiplier: 'seconds',
secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
data: {
- is_unique: false,
+ is_static: false,
+ is_public: false,
use_custom: false,
has_webhook: false
}
@@ -76,7 +77,8 @@ window.app = Vue.createApp({
simpleformDialog: {
show: false,
data: {
- is_unique: true,
+ is_static: true,
+ is_public: false,
use_custom: false,
title: 'Vouchers',
min_withdrawable: 0,
@@ -125,27 +127,38 @@ window.app = Vue.createApp({
},
closeFormDialog() {
this.formDialog.data = {
- is_unique: false,
+ is_static: false,
+ is_public: false,
use_custom: false,
has_webhook: false
}
},
simplecloseFormDialog() {
this.simpleformDialog.data = {
- is_unique: false,
+ is_static: false,
+ is_public: false,
use_custom: false
}
},
+ getNextSecret(secrets) {
+ return _.findWhere(secrets, {used: false}).k1
+ },
openQrCodeDialog(linkId) {
const link = _.findWhere(this.withdrawLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
this.qrCodeDialog.show = true
- this.activeUrl = `${window.location.origin}/withdraw/api/v1/lnurl/${link.unique_hash}`
+ this.qrCodeDialog.progress =
+ link.secrets.used == 0 ? 0 : link.secrets.used / link.secrets.total
+ const id_or_k1 = link.is_static
+ ? link.id
+ : this.getNextSecret(link.secrets.items)
+ this.activeUrl = `${window.location.origin}/withdraw/api/v1/lnurl/${id_or_k1}`
},
openUpdateDialog(linkId) {
let link = _.findWhere(this.withdrawLinks, {id: linkId})
link._data.has_webhook = link._data.webhook_url ? true : false
this.formDialog.data = _.clone(link._data)
+ this.formDialog.data.uses = 1
this.formDialog.show = true
},
sendFormData() {
@@ -185,7 +198,7 @@ window.app = Vue.createApp({
data.wait_time = 1
data.min_withdrawable = data.max_withdrawable
data.title = 'vouchers'
- data.is_unique = true
+ data.is_static = true
if (!data.use_custom) {
data.custom_url = null
@@ -217,7 +230,7 @@ window.app = Vue.createApp({
data
)
.then(response => {
- this.withdrawLinks = _.reject(this.withdrawLinks, function (obj) {
+ this.withdrawLinks = _.reject(this.withdrawLinks, obj => {
return obj.id === data.id
})
this.withdrawLinks.push(mapWithdrawLink(response.data))
diff --git a/templates/withdraw/index.html b/templates/withdraw/index.html
index 3b75e11..ac8d722 100644
--- a/templates/withdraw/index.html
+++ b/templates/withdraw/index.html
@@ -53,6 +53,7 @@ Withdraw links
type="text"
label="Link title *"
>
+
-
-
label="Webhook custom data (optional)"
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
>
+
+
+
+
+
+
+ Enable public/shareable URL.
+ This will create a shareable URL that anyone can use to
+ withdraw from this link.
+
+
+
@@ -267,18 +293,17 @@
+ Use static withdraw QR codes
Use unique withdraw QR codes to reduce `assmilking`
+ >If enabled, the same withdraw QR code will be used for every
+ withdrawal with `wait_time` in between withdrawals. This makes
+ it prone for attackers to `assmilk` your withdraw link.
- This is recommended if you are sharing the links on social
- media or print QR codes.
@@ -404,22 +429,22 @@
@update:lnurl="v => lnurl = v"
prefix="lnurlw"
>
-
- ID:
- Unique:
-
-
+
+ ID:
+ Static:
+
+
(QR code will change after each withdrawal)
- Max. withdrawable:
+ Max. withdrawable:
sat
- Wait time:
+ Wait time:
seconds
- Withdraws:
- /
-
+ Withdraws:
+ /
+
@@ -441,6 +466,7 @@
>Write to NFC
- {% for page in link %}
-
+
- {% for threes in page %}
-
- {% for one in threes %}
- |
+ |
+ |
|
- {% endfor %}
- {% endfor %}
- {% endfor %}
{% endblock %} {% block styles %}
@@ -58,12 +51,39 @@
el: '#vue',
data() {
return {
- theurl: location.protocol + '//' + location.host,
- printDialog: {
- show: true,
- data: null
+ pages: [],
+ columns: 3,
+ rows: 4,
+ url: window.location.origin,
+ links: {{ links | tojson | safe }},
+ }
+ },
+ methods: {
+ preparePages() {
+ let tempPage = []
+ let tempRow = []
+ this.links.forEach((link, index) => {
+ tempRow.push(link)
+ if ((index + 1) % this.columns === 0) {
+ tempPage.push(tempRow)
+ tempRow = []
+ }
+ if ((index + 1) % (this.columns * this.rows) === 0) {
+ this.pages.push(tempPage)
+ tempPage = []
+ }
+ })
+ if (tempRow.length > 0) {
+ tempPage.push(tempRow)
+ }
+ if (tempPage.length > 0) {
+ this.pages.push(tempPage)
}
}
+ },
+ created() {
+ this.preparePages()
+ console.log(this.pages)
}
})
diff --git a/views.py b/views.py
index 05e625d..85f8e5f 100644
--- a/views.py
+++ b/views.py
@@ -19,15 +19,17 @@ def withdraw_renderer():
return template_renderer(["withdraw/templates"])
-@withdraw_ext_generic.get("/", response_class=HTMLResponse)
-async def index(request: Request, user: User = Depends(check_user_exists)):
+@withdraw_ext_generic.get("/")
+async def index(
+ request: Request, user: User = Depends(check_user_exists)
+) -> HTMLResponse:
return withdraw_renderer().TemplateResponse(
"withdraw/index.html", {"request": request, "user": user.json()}
)
-@withdraw_ext_generic.get("/{link_id}", response_class=HTMLResponse)
-async def display(request: Request, link_id):
+@withdraw_ext_generic.get("/{link_id}")
+async def display(request: Request, link_id: str) -> HTMLResponse:
link = await get_withdraw_link(link_id)
if not link:
@@ -35,6 +37,11 @@ async def display(request: Request, link_id):
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
+ if link.is_public is False:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="Withdraw link is not public."
+ )
+
if link.open_time and link.open_time > datetime.now(timezone.utc).timestamp():
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
@@ -46,63 +53,53 @@ async def display(request: Request, link_id):
{
"request": request,
"spent": link.secrets.is_spent,
- "unique_hash": link.secrets.next_secret,
+ "secret": link.secrets.next_secret,
},
)
-@withdraw_ext_generic.get("/print/{link_id}", response_class=HTMLResponse)
-async def print_qr(request: Request, link_id):
+@withdraw_ext_generic.get("/print/{link_id}")
+async def print_qr(
+ request: Request, link_id: str, user: User = Depends(check_user_exists)
+) -> HTMLResponse:
link = await get_withdraw_link(link_id)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
-
- # if link.uses == 0:
-
- # return withdraw_renderer().TemplateResponse(
- # "withdraw/print_qr.html",
- # {"request": request, "link": link.json(), "unique": False},
- # )
- # links = []
- # count = 0
-
- # for _ in link.usescsv.split(","):
- # linkk = await get_withdraw_link(link_id, count)
- # if not linkk:
- # raise HTTPException(
- # HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
- # )
- # try:
- # lnurl = create_lnurl(linkk, request)
- # except ValueError as exc:
- # raise HTTPException(
- # status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- # detail=str(exc),
- # ) from exc
- # links.append(str(lnurl.bech32))
- # count = count + 1
- # page_link = list(chunks(links, 2))
- # linked = list(chunks(page_link, 5))
+ if link.wallet not in user.wallet_ids:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="This is not your withdraw link."
+ )
+ links = []
+ for secret in link.secrets.items:
+ url = request.url_for("withdraw.lnurl", id_or_k1=secret.k1)
+ lnurl = parse_obj_as(Lnurl, str(url))
+ links.append(str(lnurl.bech32))
return withdraw_renderer().TemplateResponse(
- "withdraw/print_qr.html", {"request": request, "link": [], "unique": True}
+ "withdraw/print_qr.html", {"request": request, "links": links}
)
-@withdraw_ext_generic.get("/csv/{link_id}", response_class=HTMLResponse)
-async def csv(req: Request, link_id):
+@withdraw_ext_generic.get("/csv/{link_id}")
+async def csv(
+ req: Request, link_id: str, user: User = Depends(check_user_exists)
+) -> StreamingResponse:
link = await get_withdraw_link(link_id)
if not link:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist."
)
+ if link.wallet not in user.wallet_ids:
+ raise HTTPException(
+ status_code=HTTPStatus.FORBIDDEN, detail="This is not your withdraw link."
+ )
buffer = io.StringIO()
count = 0
- for _ in link.secrets.items:
- url = req.url_for("withdraw.lnurl_callback", id_or_secret=link_id)
+ for secret in link.secrets.items:
+ url = req.url_for("withdraw.lnurl", id_or_k1=secret.k1)
lnurl = parse_obj_as(Lnurl, str(url))
buffer.write(f"{lnurl.bech32!s}\n")
count += 1
diff --git a/views_api.py b/views_api.py
index aa8785e..b8e3a11 100644
--- a/views_api.py
+++ b/views_api.py
@@ -77,6 +77,8 @@ async def api_link_update(
)
for k, v in data.dict().items():
+ if k == "uses":
+ continue
setattr(link, k, v)
return await update_withdraw_link(link)
diff --git a/views_lnurl.py b/views_lnurl.py
index a487fc1..6c4abed 100644
--- a/views_lnurl.py
+++ b/views_lnurl.py
@@ -24,7 +24,46 @@
withdraw_ext_lnurl = APIRouter(prefix="/api/v1/lnurl")
-@withdraw_ext_lnurl.get("/{id_or_k1}")
+# note important that this endpoint is defined before the dynamic /{id_or_k1} endpoint
+@withdraw_ext_lnurl.get("/cb", name="withdraw.lnurl_callback")
+async def api_lnurl_callback(
+ k1: str, pr: str
+) -> LnurlErrorResponse | LnurlSuccessResponse:
+
+ link = await get_withdraw_link_by_k1(k1)
+ if not link:
+ return LnurlErrorResponse(reason="Invalid k1.")
+
+ secret = link.secrets.get_secret(k1)
+ if not secret:
+ return LnurlErrorResponse(reason="Invalid k1.")
+
+ if secret.used:
+ return LnurlErrorResponse(reason="Withdraw is spent.")
+
+ # IMPORTANT: update the link in the db before paying the invoice
+ # so that concurrent requests can't use the same secret
+ link.open_time = int(datetime.now().timestamp()) + link.wait_time
+ link.secrets.use_secret(k1)
+ await update_withdraw_link(link)
+
+ try:
+ payment = await pay_invoice(
+ wallet_id=link.wallet,
+ payment_request=pr,
+ max_sat=link.max_withdrawable,
+ extra={"tag": "withdraw", "withdraw_id": link.id},
+ )
+ except PaymentError as exc:
+ return LnurlErrorResponse(reason=f"Payment error: {exc.message}")
+
+ if link.webhook_url:
+ await dispatch_webhook(link, payment, pr)
+
+ return LnurlSuccessResponse()
+
+
+@withdraw_ext_lnurl.get("/{id_or_k1}", name="withdraw.lnurl")
async def api_lnurl_response(
request: Request, id_or_k1: str
) -> LnurlWithdrawResponse | LnurlErrorResponse:
@@ -51,6 +90,12 @@ async def api_lnurl_response(
if secret.used:
return LnurlErrorResponse(reason="Withdraw is spent.")
+ now = int(datetime.now().timestamp())
+ if now < link.open_time:
+ return LnurlErrorResponse(
+ reason=f"wait link open_time {link.open_time - now} seconds."
+ )
+
url = request.url_for("withdraw.lnurl_callback")
try:
callback_url = parse_obj_as(CallbackUrl, str(url))
@@ -66,49 +111,6 @@ async def api_lnurl_response(
)
-@withdraw_ext_lnurl.get("/cb", name="withdraw.lnurl_callback")
-async def api_lnurl_callback(
- k1: str, pr: str
-) -> LnurlErrorResponse | LnurlSuccessResponse:
-
- link = await get_withdraw_link_by_k1(k1)
- if not link:
- return LnurlErrorResponse(reason="Invalid k1.")
-
- now = int(datetime.now().timestamp())
- if now < link.open_time:
- return LnurlErrorResponse(
- reason=f"wait link open_time {link.open_time - now} seconds."
- )
-
- secret = link.secrets.get_secret(k1)
- if not secret:
- return LnurlErrorResponse(reason="Invalid k1.")
-
- if secret.used:
- return LnurlErrorResponse(reason="Withdraw is spent.")
-
- # IMPORTANT: update the link in the db before paying the invoice
- # so that concurrent requests can't use the same secret
- link.secrets.use_secret(k1)
- await update_withdraw_link(link)
-
- try:
- payment = await pay_invoice(
- wallet_id=link.wallet,
- payment_request=pr,
- max_sat=link.max_withdrawable,
- extra={"tag": "withdraw", "withdraw_id": link.id},
- )
- except PaymentError as exc:
- return LnurlErrorResponse(reason=f"Payment error: {exc.message}")
-
- if link.webhook_url:
- await dispatch_webhook(link, payment, pr)
-
- return LnurlSuccessResponse()
-
-
async def dispatch_webhook(
link: WithdrawLink, payment: Payment, payment_request: str
) -> None:
From 03babf1d9d75dd22b375f4df979bbd2648005297 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Tue, 26 Aug 2025 12:54:23 +0200
Subject: [PATCH 05/10] use admin key
---
static/js/index.js | 2 +-
views_api.py | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/static/js/index.js b/static/js/index.js
index 0599289..fcbfb22 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -114,7 +114,7 @@ window.app = Vue.createApp({
.request(
'GET',
`/withdraw/api/v1/links?all_wallets=true&limit=${query.limit}&offset=${query.offset}`,
- this.g.user.wallets[0].inkey
+ this.g.user.wallets[0].adminkey
)
.then(response => {
this.withdrawLinks = response.data.data.map(mapWithdrawLink)
diff --git a/views_api.py b/views_api.py
index b8e3a11..82b9f9c 100644
--- a/views_api.py
+++ b/views_api.py
@@ -3,7 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from lnbits.core.crud import get_user
from lnbits.core.models import SimpleStatus, WalletTypeInfo
-from lnbits.decorators import require_admin_key, require_invoice_key
+from lnbits.decorators import require_admin_key
from .crud import (
create_withdraw_link,
@@ -19,7 +19,7 @@
@withdraw_ext_api.get("/links")
async def api_links(
- key_info: WalletTypeInfo = Depends(require_invoice_key),
+ key_info: WalletTypeInfo = Depends(require_admin_key),
all_wallets: bool = Query(False),
offset: int = Query(0),
limit: int = Query(0),
@@ -35,7 +35,7 @@ async def api_links(
@withdraw_ext_api.get("/links/{link_id}")
async def api_link_retrieve(
- link_id: str, key_info: WalletTypeInfo = Depends(require_invoice_key)
+ link_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
) -> WithdrawLink:
link = await get_withdraw_link(link_id)
From 0e103b3126a30f0e572352a15b80a65cac029528 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Wed, 27 Aug 2025 08:27:34 +0200
Subject: [PATCH 06/10] style q-table
---
static/js/index.js | 61 ++++++-----------------------------
templates/withdraw/index.html | 49 +++++++++++-----------------
2 files changed, 28 insertions(+), 82 deletions(-)
diff --git a/static/js/index.js b/static/js/index.js
index fcbfb22..ce2e98e 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -29,22 +29,10 @@ window.app = Vue.createApp({
}
},
{
- name: 'wait_time',
- align: 'right',
- label: 'Wait',
- field: 'wait_time'
- },
- {
- name: 'uses',
- align: 'right',
- label: 'Uses',
- field: 'uses'
- },
- {
- name: 'uses_left',
+ name: 'progress',
align: 'right',
- label: 'Uses left',
- field: 'uses_left'
+ label: 'Used / Total',
+ field: 'secrets'
},
{
name: 'max_withdrawable',
@@ -54,6 +42,12 @@ window.app = Vue.createApp({
format: v => {
return new Intl.NumberFormat(LOCALE).format(v)
}
+ },
+ {
+ name: 'wait_time',
+ align: 'right',
+ label: 'Wait',
+ field: 'wait_time'
}
],
pagination: {
@@ -62,7 +56,6 @@ window.app = Vue.createApp({
rowsNumber: 0
}
},
- nfcTagWriting: false,
formDialog: {
show: false,
secondMultiplier: 'seconds',
@@ -276,42 +269,6 @@ window.app = Vue.createApp({
})
})
},
- async writeNfcTag(lnurl) {
- try {
- if (typeof NDEFReader == 'undefined') {
- throw {
- toString: function () {
- return 'NFC not supported on this device or browser.'
- }
- }
- }
-
- const ndef = new NDEFReader()
-
- this.nfcTagWriting = true
- this.$q.notify({
- message: 'Tap your NFC tag to write the LNURL-withdraw link to it.'
- })
-
- await ndef.write({
- records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}]
- })
-
- this.nfcTagWriting = false
- this.$q.notify({
- type: 'positive',
- message: 'NFC tag written successfully.'
- })
- } catch (error) {
- this.nfcTagWriting = false
- this.$q.notify({
- type: 'negative',
- message: error
- ? error.toString()
- : 'An unexpected error has occurred.'
- })
- }
- },
exportCSV() {
LNbits.utils.exportCSV(
this.withdrawLinksTable.columns,
diff --git a/templates/withdraw/index.html b/templates/withdraw/index.html
index ac8d722..03c2d29 100644
--- a/templates/withdraw/index.html
+++ b/templates/withdraw/index.html
@@ -46,7 +46,6 @@ Withdraw links
:props="props"
v-text="col.label"
>
-
@@ -101,19 +100,24 @@ Withdraw links
color="pink"
>
-
-
-
-
- Webhook to
-
+
+
+
+
+
+
+ /
+
+
+
+
@@ -423,7 +427,7 @@
-
+
lnurl = v"
@@ -450,21 +454,6 @@
>
- Copy LNURL
- Write to NFC
Date: Wed, 27 Aug 2025 08:27:38 +0200
Subject: [PATCH 07/10] fix display
---
templates/withdraw/display.html | 17 +----------------
views.py | 17 ++++++++++++++---
2 files changed, 15 insertions(+), 19 deletions(-)
diff --git a/templates/withdraw/display.html b/templates/withdraw/display.html
index 4d06e11..927dc8e 100644
--- a/templates/withdraw/display.html
+++ b/templates/withdraw/display.html
@@ -11,22 +11,9 @@
lnurl = v"
>
-
- Copy LNURL
-
-
@@ -55,9 +42,7 @@
data() {
return {
spent: {{ 'true' if spent else 'false' }},
- url: `${window.location.origin}/withdraw/api/v1/lnurl/{{ unique_hash }}`,
- lnurl: '',
- nfcTagWriting: false
+ url: `${window.location.origin}/withdraw/api/v1/lnurl/{{ id_or_k1 }}`,
}
}
})
diff --git a/views.py b/views.py
index 85f8e5f..8879779 100644
--- a/views.py
+++ b/views.py
@@ -42,18 +42,29 @@ async def display(request: Request, link_id: str) -> HTMLResponse:
status_code=HTTPStatus.FORBIDDEN, detail="Withdraw link is not public."
)
- if link.open_time and link.open_time > datetime.now(timezone.utc).timestamp():
+ if (
+ not link.is_static
+ and link.open_time
+ and link.open_time > datetime.now(timezone.utc).timestamp()
+ ):
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Withdraw link is not yet active.",
)
+ secret = link.secrets.next_secret
+ if not secret:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail="Withdraw link is out of withdraws.",
+ )
+
return withdraw_renderer().TemplateResponse(
"withdraw/display.html",
{
"request": request,
- "spent": link.secrets.is_spent,
- "secret": link.secrets.next_secret,
+ "spent": link.secrets.is_spent or secret.used,
+ "id_or_k1": link.id if link.is_static else secret.k1,
},
)
From bdaa6d4c268cefc323daae7a0c41585367a8a295 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Wed, 27 Aug 2025 08:37:57 +0200
Subject: [PATCH 08/10] fixup!
---
templates/withdraw/print_qr.html | 1 -
1 file changed, 1 deletion(-)
diff --git a/templates/withdraw/print_qr.html b/templates/withdraw/print_qr.html
index 8eae1b6..01985e9 100644
--- a/templates/withdraw/print_qr.html
+++ b/templates/withdraw/print_qr.html
@@ -83,7 +83,6 @@
},
created() {
this.preparePages()
- console.log(this.pages)
}
})
From b015705df80cfe58dd563964001c68e282dabe33 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Wed, 27 Aug 2025 08:50:44 +0200
Subject: [PATCH 09/10] opentime only for static
---
views_lnurl.py | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/views_lnurl.py b/views_lnurl.py
index 6c4abed..95afb92 100644
--- a/views_lnurl.py
+++ b/views_lnurl.py
@@ -79,6 +79,12 @@ async def api_lnurl_response(
if not secret:
return LnurlErrorResponse(reason="Withdraw is spent.")
+ now = int(datetime.now().timestamp())
+ if now < link.open_time:
+ return LnurlErrorResponse(
+ reason=f"wait link open_time {link.open_time - now} seconds."
+ )
+
# non-static links are identified by their k1
else:
link = await get_withdraw_link_by_k1(id_or_k1)
@@ -90,12 +96,6 @@ async def api_lnurl_response(
if secret.used:
return LnurlErrorResponse(reason="Withdraw is spent.")
- now = int(datetime.now().timestamp())
- if now < link.open_time:
- return LnurlErrorResponse(
- reason=f"wait link open_time {link.open_time - now} seconds."
- )
-
url = request.url_for("withdraw.lnurl_callback")
try:
callback_url = parse_obj_as(CallbackUrl, str(url))
From 81b0f7a1527d5e489d149eb6e66e9ce505578f60 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?dni=20=E2=9A=A1?=
Date: Wed, 27 Aug 2025 08:52:51 +0200
Subject: [PATCH 10/10] remove ignores
---
pyproject.toml | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 3d53dba..687afd1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,9 +53,7 @@ line-length = 88
# RUF - ruff
# B - bugbear
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
-# UP007: pyupgrade: use X | Y instead of Optional. (python3.10)
-# C901 `api_link_create_or_update` is too complex (15 > 10)
-ignore = ["UP007", "C901"]
+ignore = []
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]