From 2b6cacebe1ed9968a632b0dcb0ddd600a2cd4301 Mon Sep 17 00:00:00 2001 From: dni Date: Tue, 2 Jun 2026 23:20:00 +0200 Subject: [PATCH 1/3] feat: onchain payments :( --- models.py | 6 ++ services.py | 43 ++++++++++ static/js/display.js | 58 +++++++++++-- static/js/display.vue | 46 ++++++++-- static/js/index.js | 49 ++++++++++- static/js/index.vue | 44 ++++++++++ views_api.py | 190 ++++++++++++++++++++++++++++++++++++++---- 7 files changed, 402 insertions(+), 34 deletions(-) diff --git a/models.py b/models.py index 331ad6d..a16f7dd 100644 --- a/models.py +++ b/models.py @@ -45,6 +45,8 @@ class EventExtra(BaseModel): nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" + onchain_enabled: bool = False + onchain_wallet_id: str | None = None class CreateEvent(BaseModel): @@ -110,6 +112,7 @@ class TicketExtra(BaseModel): email_notification_sent: bool = False nostr_notification_sent: bool = False refunded: bool = False + onchain: bool = False class CreateTicket(BaseModel): @@ -167,6 +170,9 @@ 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 class TicketFilters(FilterModel): diff --git a/services.py b/services.py index 057026b..84c16e0 100644 --- a/services.py +++ b/services.py @@ -5,7 +5,9 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from html import escape +from typing import Any +import httpx from lnbits.core.models.users import UserNotifications from lnbits.core.services.notifications import send_user_notification from lnbits.helpers import is_valid_email_address @@ -29,6 +31,47 @@ ) +async def fetch_watchonly_config(api_key: str) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/config", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallets(api_key: str, network: str) -> list[dict[str, Any]]: + async with httpx.AsyncClient() as client: + resp = await client.get( + url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/wallet", + headers={"X-API-KEY": api_key}, + params={"network": network}, + ) + resp.raise_for_status() + return resp.json() + + +async def fetch_watchonly_wallet(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/wallet/{wallet_id}", + headers={"X-API-KEY": api_key}, + ) + resp.raise_for_status() + 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 set_ticket_paid(ticket: Ticket) -> Ticket: if ticket.paid: return ticket diff --git a/static/js/display.js b/static/js/display.js index 498cd2c..41f25df 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -27,7 +27,11 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null }, paymentDismissMsg: null, paymentWebsocket: null @@ -79,6 +83,31 @@ window.PageEventsDisplay = { }, allowNostrNotifications() { return Boolean(this.event?.extra?.nostr_notifications) + }, + allowOnchain() { + return Boolean(this.event?.extra?.onchain_enabled) + }, + showPaymentMethodSelector() { + return this.allowFiatCheckout || this.allowOnchain + }, + paymentMethodOptions() { + const options = [{label: 'Lightning', value: 'lightning'}] + if (this.allowFiatCheckout) { + options.push({label: this.fiatCheckoutLabel, value: 'fiat'}) + } + if (this.allowOnchain) { + options.push({label: 'Bitcoin', value: 'onchain'}) + } + 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: { @@ -131,7 +160,11 @@ window.PageEventsDisplay = { show: false, status: 'pending', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null } }, nameValidation(val) { @@ -169,7 +202,11 @@ window.PageEventsDisplay = { show: false, status: 'complete', paymentReq: null, - isFiat: false + isFiat: false, + isOnchain: false, + onchainAddress: null, + onchainAmountSat: 0, + mempoolEndpoint: null } this.ticketLink = { show: true, @@ -192,15 +229,18 @@ window.PageEventsDisplay = { promo_code: this.formDialog.data.promo_code || null, refund_address: this.formDialog.data.refund || null, nostr_identifier: this.formDialog.data.nostr_identifier || null, - payment_method: this.allowFiatCheckout + payment_method: this.showPaymentMethodSelector ? this.formDialog.data.payment_method : 'lightning' } ) - const isFiat = Boolean(data.is_fiat) + const isOnchain = Boolean(data.onchain_address) + const isFiat = !isOnchain && Boolean(data.is_fiat) this.paymentReq = isFiat ? data.fiat_payment_request || null - : data.payment_request + : isOnchain + ? null + : data.payment_request this.paymentHash = data.payment_hash this.paymentDismissMsg = Quasar.Notify.create({ @@ -211,7 +251,11 @@ window.PageEventsDisplay = { show: true, status: 'pending', paymentReq: this.paymentReq, - isFiat + isFiat, + isOnchain, + onchainAddress: data.onchain_address || null, + onchainAmountSat: data.onchain_amount_sat || 0, + mempoolEndpoint: data.onchain_mempool_endpoint || null } if (isFiat && this.paymentReq) { window.open(this.paymentReq, '_blank', 'noopener') diff --git a/static/js/display.vue b/static/js/display.vue index 382f25a..cf9e211 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -80,20 +80,14 @@ " >
-
+
-
+
Close
+ +
+
Pay with Bitcoin
+ +
+ {{ (receive.onchainAmountSat / 100000000).toFixed(8) }} BTC +
+
+ {{ receive.onchainAddress }} +
+
+
+ Copy address + View on mempool + Close +
+
4 ? `${value.slice(0, 4)}...` : value }, + async loadOnchainWallets() { + const wallet = _.findWhere(this.g.user.wallets, { + id: this.formDialog.data.wallet + }) + if (!wallet) return + try { + const {data} = await LNbits.api.request( + 'GET', + '/events/api/v1/events/onchain/status', + wallet.adminkey + ) + this.onchainWallets = data.available ? data.wallets || [] : [] + } catch { + this.onchainWallets = [] + } + }, + async confirmOnchainTicket(ticket) { + const wallet = _.findWhere(this.g.user.wallets, {id: ticket.wallet}) + if (!wallet) return + try { + await LNbits.api.request( + 'PUT', + `/events/api/v1/tickets/${ticket.id}/onchain-confirm`, + wallet.adminkey + ) + Quasar.Notify.create({ + type: 'positive', + message: 'Onchain payment confirmed.', + icon: null + }) + await this.getTickets() + await this.getAllTickets() + } catch (error) { + LNbits.utils.notifyApiError(error) + } + }, primaryTicketWave(data = this.formDialog.data) { if (!data.extra) data.extra = {} if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { @@ -403,6 +440,8 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + onchain_enabled: false, + onchain_wallet_id: null, ticket_waves: [ { id: 'primary', @@ -425,6 +464,12 @@ window.PageEvents = { } } this.formDialog.show = true + if ( + this.formDialog.data.wallet && + this.formDialog.data.extra?.onchain_enabled + ) { + this.loadOnchainWallets() + } }, resetEventDialog() { this.formDialog.show = false @@ -437,6 +482,8 @@ window.PageEvents = { min_tickets: 1, email_notifications: false, nostr_notifications: false, + onchain_enabled: false, + onchain_wallet_id: null, ticket_waves: [ { id: 'primary', diff --git a/static/js/index.vue b/static/js/index.vue index b90e922..89d976f 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -265,6 +265,18 @@ > Resend ticket email + + Confirm onchain payment + @@ -602,6 +614,38 @@
+ +
+
+ Accept Bitcoin onchain payments. Requires the Watchonly extension. +
+ + +
+
+
dict[str, Any]: + try: + config = await fetch_watchonly_config(wallet.inkey) + network = config.get("network") + if not network: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Watchonly extension returned an invalid network.", + ) + wallets = await fetch_watchonly_wallets(wallet.inkey, network) + except HTTPException: + raise + except Exception as exc: + return { + "available": False, + "message": f"Watchonly extension is not reachable: {exc!s}", + "network": None, + "wallets": [], + "mempool_endpoint": None, + } + return { + "available": True, + "message": None, + "network": network, + "wallets": wallets, + "mempool_endpoint": config.get("mempool_endpoint"), + } + + +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"} @@ -120,6 +198,13 @@ async def api_events( return await get_events(wallet_ids) +@events_api_router.get("/onchain/status") +async def api_onchain_status( + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> dict[str, Any]: + return await _get_watchonly_status(wallet.wallet) + + @events_api_router.get("/{event_id}", response_model=PublicEvent) async def api_get_event(event_id: str) -> Event: event = await get_event(event_id) @@ -371,7 +456,7 @@ async def api_ticket_create( refund_address = data.refund_address nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None payment_method = (data.payment_method or "lightning").lower() - if payment_method not in {"lightning", "fiat"}: + if payment_method not in {"lightning", "fiat", "onchain"}: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Unsupported payment method.", @@ -438,6 +523,10 @@ 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": if _is_fiat_currency(selected_wave.currency): invoice_unit = selected_wave.currency @@ -461,20 +550,53 @@ async def api_ticket_create( status_code=HTTPStatus.BAD_REQUEST, 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: + raise HTTPException( + 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, + ) + address_data = await fetch_onchain_address( + wallet_record.inkey, event.extra.onchain_wallet_id or "" + ) + onchain_address = address_data.get("address") + onchain_mempool_endpoint = validation.get("mempool_endpoint") else: invoice_unit = "sat" - 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, - ), - ) + if payment_method == "onchain": + payment = await create_payment_request( + wallet_id=event.wallet, + invoice_data=CreateInvoice( + out=False, + amount=onchain_amount_sat, + 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, + ), + ) await create_ticket( payment_hash=payment.payment_hash, wallet=event.wallet, @@ -488,7 +610,8 @@ async def api_ticket_create( "refund_address": refund_address, "nostr_identifier": nostr_identifier, "ticket_base_url": str(request.base_url).rstrip("/"), - "sats_paid": payment.sat, + "sats_paid": onchain_amount_sat if payment_method == "onchain" else payment.sat, + "onchain": payment_method == "onchain", }, ) @@ -498,6 +621,9 @@ 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, ) @@ -559,6 +685,36 @@ async def api_ticket_delete( await delete_ticket(ticket_id) +@tickets_api_router.put("/{payment_hash}/onchain-confirm") +async def api_ticket_onchain_confirm( + payment_hash: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Ticket: + ticket = await get_ticket(payment_hash) + if not ticket: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Ticket does not exist." + ) + if ticket.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.") + if ticket.paid: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Ticket already paid." + ) + if not ticket.extra.onchain: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Ticket is not an onchain payment.", + ) + payment = await get_standalone_payment(payment_hash) + if not payment: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Payment does not exist." + ) + await internal_invoice_queue_put(cast(Payment, payment)) + return ticket + + @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult) async def api_ticket_resend_email( ticket_id: str, From 6d3e9b95163c95ce384a5f5d02c698f9f4e9fe07 Mon Sep 17 00:00:00 2001 From: dni Date: Tue, 2 Jun 2026 23:34:27 +0200 Subject: [PATCH 2/3] fixes --- static/js/display.vue | 2 +- static/js/index.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/static/js/display.vue b/static/js/display.vue index cf9e211..6e4c5a7 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -134,7 +134,7 @@ diff --git a/static/js/index.vue b/static/js/index.vue index 89d976f..1de5b42 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -638,7 +638,7 @@ label="Watchonly wallet" :options="onchainWallets" option-value="id" - option-label="name" + option-label="title" emit-value map-options hint="Bitcoin watchonly wallet for receiving onchain payments" From 40f2c8b70e0e2b16bbe80a13750d9d064a1aeba0 Mon Sep 17 00:00:00 2001 From: dni Date: Tue, 2 Jun 2026 23:39:01 +0200 Subject: [PATCH 3/3] fixes --- static/js/display.js | 3 ++- static/js/display.vue | 23 +++++++++++++++++++---- static/js/index.vue | 12 ++++++++---- views_api.py | 14 ++++++++------ 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/static/js/display.js b/static/js/display.js index 41f25df..a5cc160 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -106,7 +106,8 @@ window.PageEventsDisplay = { return `bitcoin:${this.receive.onchainAddress}?amount=${btc}` }, mempoolAddressUrl() { - if (!this.receive.onchainAddress || !this.receive.mempoolEndpoint) return null + if (!this.receive.onchainAddress || !this.receive.mempoolEndpoint) + return null return `${this.receive.mempoolEndpoint}/address/${this.receive.onchainAddress}` } }, diff --git a/static/js/display.vue b/static/js/display.vue index 6e4c5a7..d1ab493 100644 --- a/static/js/display.vue +++ b/static/js/display.vue @@ -87,7 +87,11 @@ :options="paymentMethodOptions" >
-
+
Close
- +
Pay with Bitcoin
- {{ (receive.onchainAmountSat / 100000000).toFixed(8) }} BTC + {{ + (receive.onchainAmountSat / 100000000).toFixed(8) + }} + BTC
-
+
{{ receive.onchainAddress }}
diff --git a/static/js/index.vue b/static/js/index.vue index 1de5b42..0d8ba76 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -621,7 +621,8 @@ >
- Accept Bitcoin onchain payments. Requires the Watchonly extension. + Accept Bitcoin onchain payments. Requires the Watchonly + extension.