From bca625f79d39a0d233b05d7856cf9a43dc270898 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 29 Jun 2026 15:25:36 +0100 Subject: [PATCH 1/7] add test artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0152b6e..8d6aa84 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ __pycache__ node_modules .mypy_cache .venv + +# test data +data/ From e41c9769111225fb90513733c54c81bc77ccfe11 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 29 Jun 2026 16:22:19 +0100 Subject: [PATCH 2/7] add expiration time --- crud.py | 2 ++ migrations.py | 6 ++++++ models.py | 15 ++++++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crud.py b/crud.py index bca32d8..3531106 100644 --- a/crud.py +++ b/crud.py @@ -33,6 +33,8 @@ async def create_withdraw_link( webhook_headers=data.webhook_headers, webhook_body=data.webhook_body, custom_url=data.custom_url, + enabled=data.enabled, + validity_seconds=data.validity_seconds, number=0, ) await db.insert("withdraw.withdraw_link", withdraw_link) diff --git a/migrations.py b/migrations.py index dccccde..1ce40d7 100644 --- a/migrations.py +++ b/migrations.py @@ -143,3 +143,9 @@ async def m008_add_enabled_column(db): async def m009_add_currency(db): await db.execute("ALTER TABLE withdraw.withdraw_link ADD COLUMN currency TEXT;") + + +async def m010_add_validity_seconds(db): + await db.execute( + "ALTER TABLE withdraw.withdraw_link ADD COLUMN validity_seconds INTEGER NOT NULL DEFAULT 0;" + ) diff --git a/models.py b/models.py index 9c5cc86..1bf87d2 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from fastapi import Query from pydantic import BaseModel, Field @@ -17,6 +17,7 @@ class CreateWithdrawData(BaseModel): custom_url: str = Query(None) enabled: bool = Query(True) currency: str = Query(None) + validity_seconds: int = Query(0, ge=0) class WithdrawLink(BaseModel): @@ -41,6 +42,7 @@ class WithdrawLink(BaseModel): created_at: datetime enabled: bool = Query(True) currency: str = Query(None) + validity_seconds: int = Query(0) lnurl: str | None = Field( default=None, no_database=True, @@ -61,6 +63,17 @@ class WithdrawLink(BaseModel): def is_spent(self) -> bool: return self.used >= self.uses + @property + def expires_at(self) -> datetime | None: + if self.validity_seconds <= 0: + return None + return self.created_at + timedelta(seconds=self.validity_seconds) + + @property + def is_expired(self) -> bool: + expires_at = self.expires_at + return bool(expires_at and datetime.now(expires_at.tzinfo) > expires_at) + class HashCheck(BaseModel): hash: bool From 91e5ffd5c54a4f6b6d23026f01d2442b57822760 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 29 Jun 2026 16:26:45 +0100 Subject: [PATCH 3/7] reject expired lnurl claims --- views.py | 11 +++++++++++ views_lnurl.py | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/views.py b/views.py index 7313a87..4956712 100644 --- a/views.py +++ b/views.py @@ -46,6 +46,7 @@ async def display(request: Request, link_id): { "request": request, "spent": link.is_spent, + "expired": link.is_expired, "lnurl_url": str(lnurl.url), "enabled": link.enabled, }, @@ -60,6 +61,11 @@ async def print_qr(request: Request, link_id): status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) + if link.is_expired: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw has expired." + ) + if link.uses == 0: return withdraw_renderer().TemplateResponse( "withdraw/print_qr.html", @@ -111,6 +117,11 @@ async def csv(request: Request, link_id): status_code=HTTPStatus.NOT_FOUND, detail="Withdraw link does not exist." ) + if link.is_expired: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw has expired." + ) + if link.uses == 0: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Withdraw is spent." diff --git a/views_lnurl.py b/views_lnurl.py index 616cbdd..f34fde4 100644 --- a/views_lnurl.py +++ b/views_lnurl.py @@ -48,6 +48,9 @@ async def api_lnurl_response( if not link.enabled: return LnurlErrorResponse(reason="Withdraw link is disabled.") + if link.is_expired: + return LnurlErrorResponse(reason="Withdraw link has expired.") + if link.is_spent: return LnurlErrorResponse(reason="Withdraw is spent.") @@ -100,6 +103,9 @@ async def api_lnurl_callback( if not link.enabled: return LnurlErrorResponse(reason="Withdraw link is disabled.") + if link.is_expired: + return LnurlErrorResponse(reason="Withdraw link has expired.") + bolt11 = decode_bolt11(pr) if not bolt11.amount_msat: return LnurlErrorResponse(reason="0 amount invoices are not supported.") @@ -221,6 +227,9 @@ async def api_lnurl_multi_response( if not link.enabled: return LnurlErrorResponse(reason="Withdraw link is disabled.") + if link.is_expired: + return LnurlErrorResponse(reason="Withdraw link has expired.") + if link.is_spent: return LnurlErrorResponse(reason="Withdraw is spent.") From 5696b102bceb7443276c1acc9d59c0308f236f84 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 29 Jun 2026 16:27:19 +0100 Subject: [PATCH 4/7] UI show expiration --- static/js/index.js | 65 ++++++++++++++++++++++++++++++++- templates/withdraw/display.html | 5 ++- templates/withdraw/index.html | 49 +++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index c19c145..57e78ff 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -2,6 +2,27 @@ const mapWithdrawLink = function (obj) { obj._data = _.clone(obj) obj.uses_left = obj.uses - obj.used obj._data.use_custom = Boolean(obj.custom_url) + obj.expires_at = obj.validity_seconds + ? new Date( + new Date(obj.created_at).getTime() + obj.validity_seconds * 1000 + ).toISOString() + : null + obj.is_expired = obj.expires_at + ? new Date(obj.expires_at) < new Date() + : false + obj._data.validity_multiplier = 'days' + obj._data.validity_amount = null + if (obj.validity_seconds) { + if (obj.validity_seconds % 2592000 === 0) { + obj._data.validity_multiplier = 'months' + obj._data.validity_amount = obj.validity_seconds / 2592000 + } else if (obj.validity_seconds % 604800 === 0) { + obj._data.validity_multiplier = 'weeks' + obj._data.validity_amount = obj.validity_seconds / 604800 + } else { + obj._data.validity_amount = obj.validity_seconds / 86400 + } + } if (obj.currency) { obj.min_withdrawable = obj.min_withdrawable / 100 obj.max_withdrawable = obj.max_withdrawable / 100 @@ -41,6 +62,15 @@ window.app = Vue.createApp({ label: 'Wait', field: 'wait_time' }, + { + name: 'expires_at', + align: 'left', + label: 'Expires', + field: 'expires_at', + format: function (val) { + return val ? new Date(val).toLocaleString() : 'Never' + } + }, { name: 'uses', align: 'right', @@ -84,6 +114,8 @@ window.app = Vue.createApp({ show: false, secondMultiplier: 'seconds', secondMultiplierOptions: ['seconds', 'minutes', 'hours'], + validityMultiplier: 'days', + validityMultiplierOptions: ['days', 'weeks', 'months'], data: { is_unique: false, use_custom: false, @@ -93,6 +125,8 @@ window.app = Vue.createApp({ }, simpleformDialog: { show: false, + validityMultiplier: 'days', + validityMultiplierOptions: ['days', 'weeks', 'months'], data: { is_unique: true, use_custom: false, @@ -161,18 +195,22 @@ window.app = Vue.createApp({ }) }, closeFormDialog() { + this.formDialog.validityMultiplier = 'days' this.formDialog.data = { is_unique: false, use_custom: false, has_webhook: false, - enabled: true + enabled: true, + validity_seconds: 0 } }, simplecloseFormDialog() { + this.simpleformDialog.validityMultiplier = 'days' this.simpleformDialog.data = { is_unique: false, use_custom: false, - enabled: true + enabled: true, + validity_seconds: 0 } }, openQrCodeDialog(linkId) { @@ -184,6 +222,7 @@ window.app = Vue.createApp({ openUpdateDialog(linkId) { let link = _.findWhere(this.withdrawLinks, {id: linkId}) link._data.has_webhook = link._data.webhook_url ? true : false + this.formDialog.validityMultiplier = link._data.validity_multiplier this.formDialog.data = _.clone(link._data) this.formDialog.show = true }, @@ -208,6 +247,12 @@ window.app = Vue.createApp({ minutes: 60, hours: 3600 }[this.formDialog.secondMultiplier] + data.validity_seconds = this.validitySeconds( + data.validity_amount, + this.formDialog.validityMultiplier + ) + delete data.validity_amount + delete data.validity_multiplier if (data.id) { this.updateWithdrawLink(wallet, data) @@ -225,6 +270,12 @@ window.app = Vue.createApp({ data.min_withdrawable = data.max_withdrawable data.title = 'vouchers' data.is_unique = true + data.validity_seconds = this.validitySeconds( + data.validity_amount, + this.simpleformDialog.validityMultiplier + ) + delete data.validity_amount + delete data.validity_multiplier if (!data.use_custom) { data.custom_url = null @@ -267,6 +318,16 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(error) }) }, + validitySeconds(amount, unit) { + return ( + (Number(amount) || 0) * + { + days: 86400, + weeks: 604800, + months: 2592000 + }[unit] + ) + }, createWithdrawLink(wallet, data) { console.log(data) LNbits.api diff --git a/templates/withdraw/display.html b/templates/withdraw/display.html index 812c95f..1077857 100644 --- a/templates/withdraw/display.html +++ b/templates/withdraw/display.html @@ -7,8 +7,8 @@ Withdraw is spent. - Withdraw is spent.Withdraw has expired. Withdraw is disabled. data() { return { spent: {{ 'true' if spent else 'false' }}, + expired: {{ 'true' if expired else 'false' }}, url: '{{ lnurl_url }}', lnurl: '', nfcTagWriting: false, diff --git a/templates/withdraw/index.html b/templates/withdraw/index.html index 4864740..b39a6ac 100644 --- a/templates/withdraw/index.html +++ b/templates/withdraw/index.html @@ -231,6 +231,28 @@
+
+
+ +
+
+ + +
+
:default="1" label="Number of vouchers" > +
+
+ +
+
+ + +
+
@@ -475,6 +519,11 @@
sat
Wait time: seconds
+ Expires: +
Withdraws: /
From ec604a5419847875a9b56c677bb5c54884e85022 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 29 Jun 2026 16:27:42 +0100 Subject: [PATCH 5/7] chore: update readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index a1b5bc6..cc983f3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t - select wallet - set the amount each voucher will allow someone to withdraw - set the amount of vouchers you want to create - _have in mind you need to have a balance on the wallet that supports the amount \* number of vouchers_ + - optionally set an expiration time in days, weeks, or months. The expiration starts from the date the vouchers are created. Months are counted as 30 days 2. You can now print, share, display your LNURLw links or QR codes\ ![lnurlw created](https://i.imgur.com/X00twiX.jpg) - on details you can print the vouchers\ @@ -39,6 +40,7 @@ LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t - set a title for the LNURLw (it will show up in users wallet) - define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value - set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times + - optionally set an expiration time in days, weeks, or months. The expiration starts from the date the LNURLw is created. Months are counted as 30 days - LNbits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans - you can set the time in _seconds, minutes or hours_ - the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned @@ -47,4 +49,6 @@ LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t **LNbits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNbits wallet! +If no expiration time is set, the LNURLw never expires. Existing LNURLw links and vouchers keep working without an expiration unless one is added later. + ![](https://i.imgur.com/2zZ7mi8.jpg) From 7b63447d85197c8f49992bd2f8bc7794b31e88e5 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 29 Jun 2026 16:32:50 +0100 Subject: [PATCH 6/7] chore: lint --- migrations.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/migrations.py b/migrations.py index 1ce40d7..2a802e4 100644 --- a/migrations.py +++ b/migrations.py @@ -147,5 +147,6 @@ async def m009_add_currency(db): async def m010_add_validity_seconds(db): await db.execute( - "ALTER TABLE withdraw.withdraw_link ADD COLUMN validity_seconds INTEGER NOT NULL DEFAULT 0;" + "ALTER TABLE withdraw.withdraw_link " + "ADD COLUMN validity_seconds INTEGER NOT NULL DEFAULT 0;" ) From 4f1726a7a619d827e7e8fbbc2fed8925690b9e43 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Wed, 1 Jul 2026 12:23:37 +0100 Subject: [PATCH 7/7] show expiry date in printed voucher --- templates/withdraw/print_qr_custom.html | 53 ++++++++++++++++++------- views.py | 11 ++++- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/templates/withdraw/print_qr_custom.html b/templates/withdraw/print_qr_custom.html index c144a9f..db0315a 100644 --- a/templates/withdraw/print_qr_custom.html +++ b/templates/withdraw/print_qr_custom.html @@ -7,7 +7,14 @@ {% for one in page %}
... - {{ amt }} sats +
+ {{ amt }} sats +
+ Valid until {{ expiry }} +
+ {{ amt }} sats
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400'); + :root { + --qr-top: 3mm; + --qr-left: 8mm; + } body { background: rgb(204, 204, 204); + -webkit-print-color-adjust: exact !important; + print-color-adjust: exact !important; } page { background: white; @@ -44,15 +57,20 @@ padding: 1rem; width: fit-content; } - .wrapper span { + .wrapper .amount { display: block; position: absolute; font-family: 'Inter'; - font-size: 0.75rem; + font-size: 0.8rem; color: #fff; - top: calc(3.2mm + 1rem); + top: calc(2.8mm + 1rem); right: calc(4mm + 1rem); + + &.expiry { + top: calc(0.7mm + 1rem); + } } + .wrapper img.lnurlw_design { display: block; width: 187mm; @@ -62,9 +80,9 @@ .wrapper .lnurlw { display: block; position: absolute; - top: calc(3mm + 1rem); - left: calc(6mm + 1rem); - transform: rotate(45deg); + top: calc(var(--qr-top) + 1rem); + left: calc(var(--qr-left) + 1rem); + transform: rotate(-45deg); width: 27mm; } @@ -78,16 +96,17 @@ .wrapper { padding: 0px !important; } - .wrapper span { - top: 3mm; + .wrapper .amount { + top: 2.8mm; right: 4mm; + + &.expiry { + top: 0.7mm; + } } .wrapper .lnurlw { - display: block; - position: absolute; - top: 3mm; - left: 6mm; - transform: rotate(45deg); + top: var(--qr-top); + left: var(--qr-left); } } @@ -102,11 +121,15 @@ show: true, data: null }, - links: [] + links: [], + expiry: false } }, created() { this.links = '{{ link | tojson }}' + if ('{{ expiry }}' !== 'None') { + this.expiry = true + } } }) diff --git a/views.py b/views.py index 4956712..4e3a8a3 100644 --- a/views.py +++ b/views.py @@ -92,6 +92,8 @@ async def print_qr(request: Request, link_id): page_link = list(chunks(links, 2)) linked = list(chunks(page_link, 5)) + expiry = link.expires_at + if link.custom_url: return withdraw_renderer().TemplateResponse( "withdraw/print_qr_custom.html", @@ -101,11 +103,18 @@ async def print_qr(request: Request, link_id): "unique": True, "custom_url": link.custom_url, "amt": link.max_withdrawable, + "expiry": expiry.strftime("%Y-%m-%d") if expiry else None, }, ) return withdraw_renderer().TemplateResponse( - "withdraw/print_qr.html", {"request": request, "link": linked, "unique": True} + "withdraw/print_qr.html", + { + "request": request, + "link": linked, + "expiry": expiry.strftime("%Y-%m-%d") if expiry else None, + "unique": True, + }, )