From ddcf6cba33c812557d806313cfde5c2719c81e53 Mon Sep 17 00:00:00 2001 From: dni Date: Thu, 4 Jun 2026 15:57:59 +0200 Subject: [PATCH 01/11] feat: directly check onchain --- crud.py | 6 +++++- models.py | 2 ++ services.py | 23 +++++++++++++++++++++++ static/js/display.js | 34 +++++++++++++++++++++++++++++++--- views_api.py | 30 ++++++++++++++++++++++++++++++ 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/crud.py b/crud.py index 930915e..ccbdcdc 100644 --- a/crud.py +++ b/crud.py @@ -72,7 +72,11 @@ async def get_tickets_paginated( wallet_where.append(f":{key}") values[key] = wallet_id - where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"] + where = [ + f"wallet IN ({', '.join(wallet_where)})", + "(paid = true OR extra LIKE :onchain_pattern)", + ] + values["onchain_pattern"] = '%"onchain": true%' return await db.fetch_page( "SELECT * FROM events.ticket", diff --git a/models.py b/models.py index a16f7dd..a5399f2 100644 --- a/models.py +++ b/models.py @@ -113,6 +113,8 @@ class TicketExtra(BaseModel): nostr_notification_sent: bool = False refunded: bool = False onchain: bool = False + onchain_address: str | None = None + onchain_mempool_endpoint: str | None = None class CreateTicket(BaseModel): diff --git a/services.py b/services.py index 84c16e0..233c94b 100644 --- a/services.py +++ b/services.py @@ -72,6 +72,29 @@ async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: return resp.json() +async def check_onchain_payment(ticket: Ticket) -> bool: + address = ticket.extra.onchain_address + endpoint = ticket.extra.onchain_mempool_endpoint + expected_sats = ticket.extra.sats_paid or 0 + if not address or not endpoint: + return False + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{endpoint}/api/address/{address}/txs", + timeout=10.0, + ) + resp.raise_for_status() + txs = resp.json() + for tx in txs: + for vout in tx.get("vout", []): + if ( + vout.get("scriptpubkey_address") == address + and vout.get("value", 0) >= expected_sats + ): + return True + return False + + async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/static/js/display.js b/static/js/display.js index a5cc160..896a9e8 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -34,7 +34,8 @@ window.PageEventsDisplay = { mempoolEndpoint: null }, paymentDismissMsg: null, - paymentWebsocket: null + paymentWebsocket: null, + onchainPollTimer: null } }, async created() { @@ -148,6 +149,10 @@ window.PageEventsDisplay = { }, closeReceiveDialog() { + if (this.onchainPollTimer) { + clearTimeout(this.onchainPollTimer) + this.onchainPollTimer = null + } if (this.paymentDismissMsg) { this.paymentDismissMsg() this.paymentDismissMsg = null @@ -262,10 +267,29 @@ window.PageEventsDisplay = { window.open(this.paymentReq, '_blank', 'noopener') } this.paymentWatcher(this.paymentHash) + if (isOnchain) this.startOnchainPolling(this.paymentHash) } catch (error) { LNbits.utils.notifyApiError(error) } }, + startOnchainPolling(paymentHash) { + if (this.onchainPollTimer) clearTimeout(this.onchainPollTimer) + const poll = async () => { + if (!this.receive.show) return + try { + await LNbits.api.request( + 'POST', + `/events/api/v1/tickets/${paymentHash}/onchain-check`, + null + ) + // confirmed — WebSocket fires paymentSuccess, don't reschedule + } catch (error) { + if (!this.receive.show) return + this.onchainPollTimer = setTimeout(poll, 30000) + } + } + this.onchainPollTimer = setTimeout(poll, 30000) + }, paymentWatcher(paymentHash) { if (this.paymentWebsocket) { this.paymentWebsocket.close() @@ -291,8 +315,12 @@ window.PageEventsDisplay = { console.error('WebSocket error:', error) } ws.onclose = () => { - if (this.paymentWebsocket === ws) { - this.paymentWebsocket = null + if (this.paymentWebsocket !== ws) return + this.paymentWebsocket = null + if (this.receive.show) { + setTimeout(() => { + if (this.receive.show) this.paymentWatcher(paymentHash) + }, 3000) } } } diff --git a/views_api.py b/views_api.py index 782fd5b..3dd807b 100644 --- a/views_api.py +++ b/views_api.py @@ -66,6 +66,7 @@ get_active_ticket_waves, ) from .services import ( + check_onchain_payment, fetch_onchain_address, fetch_watchonly_config, fetch_watchonly_wallet, @@ -619,6 +620,8 @@ async def api_ticket_create( onchain_amount_sat if payment_method == "onchain" else payment.sat ), "onchain": payment_method == "onchain", + "onchain_address": onchain_address, + "onchain_mempool_endpoint": onchain_mempool_endpoint, }, ) @@ -692,6 +695,33 @@ async def api_ticket_delete( await delete_ticket(ticket_id) +@tickets_api_router.post("/{payment_hash}/onchain-check") +async def api_ticket_onchain_check(payment_hash: str) -> Ticket: + ticket = await get_ticket(payment_hash) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.paid: + return ticket + if not ticket.extra.onchain: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Not an onchain ticket.", + ) + found = await check_onchain_payment(ticket) + if not found: + raise HTTPException( + status_code=HTTPStatus.PAYMENT_REQUIRED, + detail="Payment not found on chain.", + ) + ticket = await set_ticket_paid(ticket) + send_ticket_notification_in_background(ticket) + for queue in payment_listeners.get(payment_hash, []): + queue.put_nowait(ticket) + return ticket + + @tickets_api_router.put("/{payment_hash}/onchain-confirm") async def api_ticket_onchain_confirm( payment_hash: str, From 0dd2ff9492425883318f8b0330f2906831f7d12a Mon Sep 17 00:00:00 2001 From: dni Date: Thu, 4 Jun 2026 16:10:01 +0200 Subject: [PATCH 02/11] make it not 0conf --- services.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/services.py b/services.py index 233c94b..1e80dc8 100644 --- a/services.py +++ b/services.py @@ -86,6 +86,8 @@ async def check_onchain_payment(ticket: Ticket) -> bool: resp.raise_for_status() txs = resp.json() for tx in txs: + if not tx.get("status", {}).get("confirmed"): + continue for vout in tx.get("vout", []): if ( vout.get("scriptpubkey_address") == address From 6d0060b1b92e77b3deef1005eb4aa55ed28215d4 Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 07:59:27 +0200 Subject: [PATCH 03/11] use satspay --- models.py | 2 + services.py | 11 ++++ static/js/display.js | 21 +++--- views_api.py | 151 ++++++++++++++++++++++++++++++------------- 4 files changed, 131 insertions(+), 54 deletions(-) diff --git a/models.py b/models.py index a5399f2..4a9d891 100644 --- a/models.py +++ b/models.py @@ -115,6 +115,7 @@ class TicketExtra(BaseModel): onchain: bool = False onchain_address: str | None = None onchain_mempool_endpoint: str | None = None + satspay_charge_id: str | None = None class CreateTicket(BaseModel): @@ -175,6 +176,7 @@ class TicketPaymentRequest(BaseModel): onchain_address: str | None = None onchain_mempool_endpoint: str | None = None onchain_amount_sat: int | None = None + satspay_charge_url: str | None = None class TicketFilters(FilterModel): diff --git a/services.py b/services.py index 1e80dc8..e8f063a 100644 --- a/services.py +++ b/services.py @@ -72,6 +72,17 @@ async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: return resp.json() +async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.post( + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge", + headers={"X-API-KEY": api_key}, + json=data, + ) + resp.raise_for_status() + return resp.json() + + async def check_onchain_payment(ticket: Ticket) -> bool: address = ticket.extra.onchain_address endpoint = ticket.extra.onchain_mempool_endpoint diff --git a/static/js/display.js b/static/js/display.js index 896a9e8..8b33c82 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -240,13 +240,15 @@ window.PageEventsDisplay = { : 'lightning' } ) - const isOnchain = Boolean(data.onchain_address) - const isFiat = !isOnchain && Boolean(data.is_fiat) + if (data.satspay_charge_url) { + window.location.href = data.satspay_charge_url + return + } + + const isFiat = Boolean(data.is_fiat) this.paymentReq = isFiat ? data.fiat_payment_request || null - : isOnchain - ? null - : data.payment_request + : data.payment_request this.paymentHash = data.payment_hash this.paymentDismissMsg = Quasar.Notify.create({ @@ -258,16 +260,15 @@ window.PageEventsDisplay = { status: 'pending', paymentReq: this.paymentReq, isFiat, - isOnchain, - onchainAddress: data.onchain_address || null, - onchainAmountSat: data.onchain_amount_sat || 0, - mempoolEndpoint: data.onchain_mempool_endpoint || null + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null } if (isFiat && this.paymentReq) { window.open(this.paymentReq, '_blank', 'noopener') } this.paymentWatcher(this.paymentHash) - if (isOnchain) this.startOnchainPolling(this.paymentHash) } catch (error) { LNbits.utils.notifyApiError(error) } diff --git a/views_api.py b/views_api.py index 3dd807b..f8ae23f 100644 --- a/views_api.py +++ b/views_api.py @@ -28,7 +28,7 @@ require_admin_key, require_invoice_key, ) -from lnbits.helpers import generate_filter_params_openapi +from lnbits.helpers import generate_filter_params_openapi, urlsafe_short_hash from lnbits.settings import settings from lnbits.utils.exchange_rates import ( fiat_amount_as_satoshis, @@ -67,6 +67,7 @@ ) from .services import ( check_onchain_payment, + create_satspay_charge, fetch_onchain_address, fetch_watchonly_config, fetch_watchonly_wallet, @@ -557,7 +558,6 @@ async def api_ticket_create( detail="No fiat payment provider configured for this event.", ) elif payment_method == "onchain": - invoice_unit = "sat" onchain_amount_sat = int(price) wallet_record = await get_wallet(event.wallet) if not wallet_record: @@ -565,44 +565,81 @@ async def api_ticket_create( status_code=HTTPStatus.NOT_FOUND, detail="Event wallet does not exist.", ) - validation = await _validate_watchonly_settings( - wallet=wallet_record, - onchain_enabled=event.extra.onchain_enabled, - onchain_wallet_id=event.extra.onchain_wallet_id, + if not event.extra.onchain_enabled: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Onchain payments are not enabled for this event.", + ) + if not event.extra.onchain_wallet_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="No onchain wallet configured for this event.", + ) + + ticket_id = urlsafe_short_hash() + base_url = str(request.base_url).rstrip("/") + webhook_url = f"{base_url}/events/api/v1/tickets/{ticket_id}/satspay-webhook" + complete_url = f"{base_url}/events/ticket/{ticket_id}" + + try: + charge = await create_satspay_charge( + api_key=wallet_record.inkey, + data={ + "amount": onchain_amount_sat, + "description": f"Ticket for {event.name}", + "name": name, + "onchainwallet": event.extra.onchain_wallet_id, + "lnbitswallet": event.wallet, + "webhook": webhook_url, + "completelink": complete_url, + "completelinktext": "View your ticket", + "time": 1440, + }, + ) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Failed to create SatsPay charge: {exc}", + ) from exc + + await create_ticket( + payment_hash=ticket_id, + wallet=event.wallet, + event=event.id, + name=name, + email=email, + extra={ + "applied_promo_code": promo_code, + "ticket_wave_id": selected_wave.id, + "ticket_wave_title": selected_wave.title, + "refund_address": refund_address, + "nostr_identifier": nostr_identifier, + "ticket_base_url": base_url, + "sats_paid": onchain_amount_sat, + "onchain": True, + "satspay_charge_id": charge["id"], + }, ) - address_data = await fetch_onchain_address( - wallet_record.inkey, event.extra.onchain_wallet_id or "" + + return TicketPaymentRequest( + payment_hash=ticket_id, + onchain_amount_sat=onchain_amount_sat, + satspay_charge_url=f"/satspay/{charge['id']}", ) - onchain_address = address_data.get("address") - onchain_mempool_endpoint = validation.get("mempool_endpoint") else: invoice_unit = "sat" - if payment_method == "onchain": - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=float(onchain_amount_sat or 0), - unit="sat", - internal=True, - labels=["onchain"], - memo=f"{event_id}", - extra=extra, - ), - ) - else: - payment = await create_payment_request( - wallet_id=event.wallet, - invoice_data=CreateInvoice( - out=False, - amount=fiat_amount if payment_method == "fiat" else price, - unit=invoice_unit, - fiat_provider=fiat_provider, - memo=f"{event_id}", - extra=extra, - ), - ) + payment = await create_payment_request( + wallet_id=event.wallet, + invoice_data=CreateInvoice( + out=False, + amount=fiat_amount if payment_method == "fiat" else price, + unit=invoice_unit, + fiat_provider=fiat_provider, + memo=f"{event_id}", + extra=extra, + ), + ) await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -616,12 +653,8 @@ async def api_ticket_create( "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": str(request.base_url).rstrip("/"), - "sats_paid": ( - onchain_amount_sat if payment_method == "onchain" else payment.sat - ), - "onchain": payment_method == "onchain", - "onchain_address": onchain_address, - "onchain_mempool_endpoint": onchain_mempool_endpoint, + "sats_paid": payment.sat, + "onchain": False, }, ) @@ -631,9 +664,6 @@ async def api_ticket_create( fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"), fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider, is_fiat=bool(getattr(payment, "fiat_provider", None) or fiat_provider), - onchain_address=onchain_address, - onchain_mempool_endpoint=onchain_mempool_endpoint, - onchain_amount_sat=onchain_amount_sat, ) @@ -722,6 +752,39 @@ async def api_ticket_onchain_check(payment_hash: str) -> Ticket: return ticket +@tickets_api_router.post("/{ticket_id}/satspay-webhook") +async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: + ticket = await get_ticket(ticket_id) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.paid: + return + if not ticket.extra.satspay_charge_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Not a SatsPay ticket." + ) + try: + charge_data = await request.json() + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Invalid webhook payload." + ) from exc + + if charge_data.get("id") != ticket.extra.satspay_charge_id: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Charge ID mismatch." + ) + if not charge_data.get("paid"): + return + + ticket = await set_ticket_paid(ticket) + send_ticket_notification_in_background(ticket) + for queue in payment_listeners.get(ticket_id, []): + queue.put_nowait(ticket) + + @tickets_api_router.put("/{payment_hash}/onchain-confirm") async def api_ticket_onchain_confirm( payment_hash: str, From 95b57df4f9ea1e5c57e35dfb3576521810a74e5c Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 08:02:13 +0200 Subject: [PATCH 04/11] remove unused --- models.py | 2 -- services.py | 10 ------- static/js/display.js | 69 ++++--------------------------------------- static/js/display.vue | 47 +---------------------------- views_api.py | 44 --------------------------- 5 files changed, 6 insertions(+), 166 deletions(-) diff --git a/models.py b/models.py index 4a9d891..b5438d4 100644 --- a/models.py +++ b/models.py @@ -173,8 +173,6 @@ class TicketPaymentRequest(BaseModel): fiat_payment_request: str | None = None fiat_provider: str | None = None is_fiat: bool = False - onchain_address: str | None = None - onchain_mempool_endpoint: str | None = None onchain_amount_sat: int | None = None satspay_charge_url: str | None = None diff --git a/services.py b/services.py index e8f063a..8f9ed83 100644 --- a/services.py +++ b/services.py @@ -62,16 +62,6 @@ async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any] return resp.json() -async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: - async with httpx.AsyncClient() as client: - resp = await client.get( - url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}", - headers={"X-API-KEY": api_key}, - ) - resp.raise_for_status() - return resp.json() - - async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: async with httpx.AsyncClient() as client: resp = await client.post( diff --git a/static/js/display.js b/static/js/display.js index 8b33c82..8ee34c0 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -27,15 +27,10 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null + isFiat: false }, paymentDismissMsg: null, - paymentWebsocket: null, - onchainPollTimer: null + paymentWebsocket: null } }, async created() { @@ -101,16 +96,6 @@ window.PageEventsDisplay = { } return options }, - onchainPaymentUri() { - if (!this.receive.onchainAddress) return '' - const btc = (this.receive.onchainAmountSat / 100000000).toFixed(8) - return `bitcoin:${this.receive.onchainAddress}?amount=${btc}` - }, - mempoolAddressUrl() { - if (!this.receive.onchainAddress || !this.receive.mempoolEndpoint) - return null - return `${this.receive.mempoolEndpoint}/address/${this.receive.onchainAddress}` - } }, methods: { async getEvent() { @@ -149,10 +134,6 @@ window.PageEventsDisplay = { }, closeReceiveDialog() { - if (this.onchainPollTimer) { - clearTimeout(this.onchainPollTimer) - this.onchainPollTimer = null - } if (this.paymentDismissMsg) { this.paymentDismissMsg() this.paymentDismissMsg = null @@ -162,16 +143,7 @@ window.PageEventsDisplay = { this.paymentWebsocket = null } this.paymentReq = null - this.receive = { - show: false, - status: 'pending', - paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null - } + this.receive = {show: false, status: 'pending', paymentReq: null, isFiat: false} }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g @@ -204,16 +176,7 @@ window.PageEventsDisplay = { message: 'Sent, thank you!', icon: null }) - this.receive = { - show: false, - status: 'complete', - paymentReq: null, - isFiat: false, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null - } + this.receive = {show: false, status: 'complete', paymentReq: null, isFiat: false} this.ticketLink = { show: true, data: { @@ -259,11 +222,7 @@ window.PageEventsDisplay = { show: true, status: 'pending', paymentReq: this.paymentReq, - isFiat, - isOnchain: false, - onchainAddress: null, - onchainAmountSat: 0, - mempoolEndpoint: null + isFiat } if (isFiat && this.paymentReq) { window.open(this.paymentReq, '_blank', 'noopener') @@ -273,24 +232,6 @@ window.PageEventsDisplay = { LNbits.utils.notifyApiError(error) } }, - startOnchainPolling(paymentHash) { - if (this.onchainPollTimer) clearTimeout(this.onchainPollTimer) - const poll = async () => { - if (!this.receive.show) return - try { - await LNbits.api.request( - 'POST', - `/events/api/v1/tickets/${paymentHash}/onchain-check`, - null - ) - // confirmed — WebSocket fires paymentSuccess, don't reschedule - } catch (error) { - if (!this.receive.show) return - this.onchainPollTimer = setTimeout(poll, 30000) - } - } - this.onchainPollTimer = setTimeout(poll, 30000) - }, paymentWatcher(paymentHash) { if (this.paymentWebsocket) { this.paymentWebsocket.close() diff --git a/static/js/display.vue b/static/js/display.vue index d1ab493..074bcc9 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -138,7 +138,7 @@ @@ -173,51 +173,6 @@ Close - -
-
Pay with Bitcoin
- -
- {{ - (receive.onchainAmountSat / 100000000).toFixed(8) - }} - BTC -
-
- {{ receive.onchainAddress }} -
-
-
- Copy address - View on mempool - Close -
-
dict[str, Any]: } -async def _validate_watchonly_settings( - *, - wallet, - onchain_enabled: bool, - onchain_wallet_id: str | None, -) -> dict[str, Any]: - if not onchain_enabled: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Onchain payments are not enabled for this event.", - ) - if not onchain_wallet_id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="No watchonly wallet configured for onchain payments.", - ) - status = await _get_watchonly_status(wallet) - if not status["available"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=status["message"] or "Watchonly extension is not available.", - ) - try: - watch_wallet = await fetch_watchonly_wallet(wallet.inkey, onchain_wallet_id) - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=f"Cannot access watchonly wallet: {exc!s}", - ) from exc - if watch_wallet.get("network") != status["network"]: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Watchonly wallet network does not match user watchonly config.", - ) - return { - "watch_wallet": watch_wallet, - "network": status["network"], - "mempool_endpoint": status["mempool_endpoint"], - } - - def _is_fiat_currency(currency: str | None) -> bool: return str(currency or "").lower() not in {"sat", "sats"} @@ -530,8 +488,6 @@ async def api_ticket_create( invoice_unit = selected_wave.currency fiat_amount = price fiat_provider = None - onchain_address = None - onchain_mempool_endpoint = None onchain_amount_sat = None if payment_method == "fiat": From a94c0893554d311245f5a5dbb46e8556a73ab715 Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 08:05:41 +0200 Subject: [PATCH 05/11] remove check onchain --- crud.py | 6 +----- services.py | 25 ------------------------- views_api.py | 28 ---------------------------- 3 files changed, 1 insertion(+), 58 deletions(-) diff --git a/crud.py b/crud.py index ccbdcdc..930915e 100644 --- a/crud.py +++ b/crud.py @@ -72,11 +72,7 @@ async def get_tickets_paginated( wallet_where.append(f":{key}") values[key] = wallet_id - where = [ - f"wallet IN ({', '.join(wallet_where)})", - "(paid = true OR extra LIKE :onchain_pattern)", - ] - values["onchain_pattern"] = '%"onchain": true%' + where = [f"wallet IN ({', '.join(wallet_where)})", "paid = true"] return await db.fetch_page( "SELECT * FROM events.ticket", diff --git a/services.py b/services.py index 8f9ed83..15c65a4 100644 --- a/services.py +++ b/services.py @@ -73,31 +73,6 @@ async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: return resp.json() -async def check_onchain_payment(ticket: Ticket) -> bool: - address = ticket.extra.onchain_address - endpoint = ticket.extra.onchain_mempool_endpoint - expected_sats = ticket.extra.sats_paid or 0 - if not address or not endpoint: - return False - async with httpx.AsyncClient() as client: - resp = await client.get( - f"{endpoint}/api/address/{address}/txs", - timeout=10.0, - ) - resp.raise_for_status() - txs = resp.json() - for tx in txs: - if not tx.get("status", {}).get("confirmed"): - continue - for vout in tx.get("vout", []): - if ( - vout.get("scriptpubkey_address") == address - and vout.get("value", 0) >= expected_sats - ): - return True - return False - - async def set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/views_api.py b/views_api.py index 73dbfd0..10d3ca2 100644 --- a/views_api.py +++ b/views_api.py @@ -66,7 +66,6 @@ get_active_ticket_waves, ) from .services import ( - check_onchain_payment, create_satspay_charge, fetch_watchonly_config, fetch_watchonly_wallet, @@ -681,33 +680,6 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -@tickets_api_router.post("/{payment_hash}/onchain-check") -async def api_ticket_onchain_check(payment_hash: str) -> Ticket: - ticket = await get_ticket(payment_hash) - if not ticket: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." - ) - if ticket.paid: - return ticket - if not ticket.extra.onchain: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="Not an onchain ticket.", - ) - found = await check_onchain_payment(ticket) - if not found: - raise HTTPException( - status_code=HTTPStatus.PAYMENT_REQUIRED, - detail="Payment not found on chain.", - ) - ticket = await set_ticket_paid(ticket) - send_ticket_notification_in_background(ticket) - for queue in payment_listeners.get(payment_hash, []): - queue.put_nowait(ticket) - return ticket - - @tickets_api_router.post("/{ticket_id}/satspay-webhook") async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: ticket = await get_ticket(ticket_id) From 1176e42d6e42760565023e88d0aa5f91d6e9cd9c Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 08:25:30 +0200 Subject: [PATCH 06/11] fixes --- static/js/index.vue | 5 +++-- views_api.py | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/static/js/index.vue b/static/js/index.vue index 0d8ba76..b75fd24 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -621,8 +621,9 @@ >
- Accept Bitcoin onchain payments. Requires the Watchonly - extension. + Accept Bitcoin onchain payments. Requires the + SatsPay and Watchonly + extensions to be installed and enabled.
None: ) try: charge_data = await request.json() + # SatsPay sends json=charge.json() which double-encodes the body + if isinstance(charge_data, str): + charge_data = json.loads(charge_data) + if not isinstance(charge_data, dict): + raise ValueError("Expected JSON object") except Exception as exc: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Invalid webhook payload." From b23afc30556823d5743842c6948b794a43c24824 Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 08:43:07 +0200 Subject: [PATCH 07/11] fix GET charge --- services.py | 10 ++++++++++ views_api.py | 23 +++++++++-------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/services.py b/services.py index 15c65a4..fbfac2a 100644 --- a/services.py +++ b/services.py @@ -62,6 +62,16 @@ async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any] return resp.json() +async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + async def create_satspay_charge(api_key: str, data: dict) -> dict[str, Any]: async with httpx.AsyncClient() as client: resp = await client.post( diff --git a/views_api.py b/views_api.py index e30e02a..5c13d04 100644 --- a/views_api.py +++ b/views_api.py @@ -1,5 +1,4 @@ import asyncio -import json from datetime import datetime, timezone from http import HTTPStatus from io import BytesIO @@ -68,6 +67,7 @@ ) from .services import ( create_satspay_charge, + get_satspay_charge, fetch_watchonly_config, fetch_watchonly_wallet, fetch_watchonly_wallets, @@ -694,23 +694,18 @@ async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Not a SatsPay ticket." ) + wallet = await get_wallet(ticket.wallet) + if not wallet: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist." + ) try: - charge_data = await request.json() - # SatsPay sends json=charge.json() which double-encodes the body - if isinstance(charge_data, str): - charge_data = json.loads(charge_data) - if not isinstance(charge_data, dict): - raise ValueError("Expected JSON object") + charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id) except Exception as exc: raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Invalid webhook payload." + status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}" ) from exc - - if charge_data.get("id") != ticket.extra.satspay_charge_id: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail="Charge ID mismatch." - ) - if not charge_data.get("paid"): + if not charge.get("paid"): return ticket = await set_ticket_paid(ticket) From a66ead993fd3807397c912612bc71719152a28ef Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 08:55:34 +0200 Subject: [PATCH 08/11] safeguard satspay --- views_api.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/views_api.py b/views_api.py index 5c13d04..4221348 100644 --- a/views_api.py +++ b/views_api.py @@ -1,4 +1,5 @@ import asyncio +import json from datetime import datetime, timezone from http import HTTPStatus from io import BytesIO @@ -6,6 +7,7 @@ from typing import Any import pyqrcode # type: ignore[import-untyped] +from loguru import logger from fastapi import ( APIRouter, Depends, @@ -699,13 +701,23 @@ async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist." ) - try: - charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id) - except Exception as exc: - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}" - ) from exc - if not charge.get("paid"): + # SatsPay marks the charge paid and fires the webhook in quick succession, + # so the DB read can race ahead of the commit. Retry a few times to be safe. + charge = None + for attempt in range(3): + if attempt: + await asyncio.sleep(2) + try: + charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id) + if charge.get("paid"): + break + except Exception: + charge = None + if not charge or not charge.get("paid"): + logger.warning( + f"SatsPay webhook for ticket {ticket_id}: charge" + f" {ticket.extra.satspay_charge_id} not paid after retries." + ) return ticket = await set_ticket_paid(ticket) From 67f1e984768359f0c039606ad79ba875683dc028 Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 11:06:15 +0200 Subject: [PATCH 09/11] fixes --- models.py | 1 - static/js/display.js | 16 +++++++++++++--- views_api.py | 30 ++++++++++++------------------ 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/models.py b/models.py index b5438d4..d4ebac9 100644 --- a/models.py +++ b/models.py @@ -114,7 +114,6 @@ class TicketExtra(BaseModel): refunded: bool = False onchain: bool = False onchain_address: str | None = None - onchain_mempool_endpoint: str | None = None satspay_charge_id: str | None = None diff --git a/static/js/display.js b/static/js/display.js index 8ee34c0..3aa4d18 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -95,7 +95,7 @@ window.PageEventsDisplay = { options.push({label: 'Bitcoin', value: 'onchain'}) } return options - }, + } }, methods: { async getEvent() { @@ -143,7 +143,12 @@ window.PageEventsDisplay = { this.paymentWebsocket = null } this.paymentReq = null - this.receive = {show: false, status: 'pending', paymentReq: null, isFiat: false} + this.receive = { + show: false, + status: 'pending', + paymentReq: null, + isFiat: false + } }, nameValidation(val) { const regex = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]/g @@ -176,7 +181,12 @@ window.PageEventsDisplay = { message: 'Sent, thank you!', icon: null }) - this.receive = {show: false, status: 'complete', paymentReq: null, isFiat: false} + this.receive = { + show: false, + status: 'complete', + paymentReq: null, + isFiat: false + } this.ticketLink = { show: true, data: { diff --git a/views_api.py b/views_api.py index 4221348..5750a24 100644 --- a/views_api.py +++ b/views_api.py @@ -1,5 +1,4 @@ import asyncio -import json from datetime import datetime, timezone from http import HTTPStatus from io import BytesIO @@ -7,7 +6,6 @@ from typing import Any import pyqrcode # type: ignore[import-untyped] -from loguru import logger from fastapi import ( APIRouter, Depends, @@ -37,6 +35,7 @@ get_fiat_rate_satoshis, satoshis_amount_as_fiat, ) +from loguru import logger from PIL import Image, ImageDraw from .crud import ( @@ -69,10 +68,9 @@ ) from .services import ( create_satspay_charge, - get_satspay_charge, fetch_watchonly_config, - fetch_watchonly_wallet, fetch_watchonly_wallets, + get_satspay_charge, refund_tickets, resend_ticket_email_notification, send_ticket_notification_in_background, @@ -687,10 +685,12 @@ async def api_ticket_delete( async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: ticket = await get_ticket(ticket_id) if not ticket: + logger.warning(f"SatsPay webhook: ticket {ticket_id} does not exist.") raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." ) if ticket.paid: + logger.warning(f"SatsPay webhook: ticket {ticket_id} already paid.") return if not ticket.extra.satspay_charge_id: raise HTTPException( @@ -701,22 +701,16 @@ async def api_ticket_satspay_webhook(ticket_id: str, request: Request) -> None: raise HTTPException( status_code=HTTPStatus.NOT_FOUND, detail="Ticket wallet does not exist." ) - # SatsPay marks the charge paid and fires the webhook in quick succession, - # so the DB read can race ahead of the commit. Retry a few times to be safe. - charge = None - for attempt in range(3): - if attempt: - await asyncio.sleep(2) - try: - charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id) - if charge.get("paid"): - break - except Exception: - charge = None - if not charge or not charge.get("paid"): + try: + charge = await get_satspay_charge(wallet.inkey, ticket.extra.satspay_charge_id) + except Exception as exc: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail=f"Could not verify charge: {exc}" + ) from exc + if not charge.get("paid"): logger.warning( f"SatsPay webhook for ticket {ticket_id}: charge" - f" {ticket.extra.satspay_charge_id} not paid after retries." + f" {ticket.extra.satspay_charge_id} not paid." ) return From 7e16bfcb067cb37cd5ab80dfac8cf90dc17caa4f Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 11:57:24 +0200 Subject: [PATCH 10/11] add fasttrack and 0conf --- models.py | 2 ++ static/js/index.js | 2 ++ static/js/index.vue | 14 +++++++++++++- views_api.py | 4 +++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/models.py b/models.py index d4ebac9..ac0d6b5 100644 --- a/models.py +++ b/models.py @@ -47,6 +47,8 @@ class EventExtra(BaseModel): notification_body: str = "" onchain_enabled: bool = False onchain_wallet_id: str | None = None + onchain_zeroconf: bool = False + onchain_fasttrack: bool = False class CreateEvent(BaseModel): diff --git a/static/js/index.js b/static/js/index.js index 8122006..b9c2b16 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -442,6 +442,8 @@ window.PageEvents = { nostr_notifications: false, onchain_enabled: false, onchain_wallet_id: null, + onchain_zeroconf: false, + onchain_fasttrack: false, ticket_waves: [ { id: 'primary', diff --git a/static/js/index.vue b/static/js/index.vue index b75fd24..93f9f1a 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -616,7 +616,7 @@
@@ -648,6 +648,18 @@ map-options hint="Bitcoin watchonly wallet for receiving onchain payments" > +
+ + +
diff --git a/views_api.py b/views_api.py index 5750a24..0a6b980 100644 --- a/views_api.py +++ b/views_api.py @@ -545,7 +545,9 @@ async def api_ticket_create( "description": f"Ticket for {event.name}", "name": name, "onchainwallet": event.extra.onchain_wallet_id, - "lnbitswallet": event.wallet, + "zeroconf": event.extra.onchain_zeroconf, + "fasttrack": event.extra.onchain_fasttrack, + "webhook": webhook_url, "completelink": complete_url, "completelinktext": "View your ticket", From d9b5a548f19d6bba691d8793834b5070bb58987a Mon Sep 17 00:00:00 2001 From: dni Date: Fri, 5 Jun 2026 11:59:58 +0200 Subject: [PATCH 11/11] format --- views_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/views_api.py b/views_api.py index 0a6b980..b6155e5 100644 --- a/views_api.py +++ b/views_api.py @@ -547,7 +547,6 @@ async def api_ticket_create( "onchainwallet": event.extra.onchain_wallet_id, "zeroconf": event.extra.onchain_zeroconf, "fasttrack": event.extra.onchain_fasttrack, - "webhook": webhook_url, "completelink": complete_url, "completelinktext": "View your ticket",