refactor: drop the SatsPay/watchonly on-chain surface

v1.6.8 sells tickets on-chain through SatsPay charges — a per-purchase
watchonly address, a hosted charge page, and a webhook back into the
extension. We are not adopting that: on-chain goes through the fork's
native LndRestWallet support once core can mint purpose-bound receiving
addresses (aiolabs/lnbits#53). This is the parity note on #41, applied
as its own commit so the merge it follows stays a faithful diff of what
upstream shipped.

Removed:
- services: the five SatsPay/watchonly HTTP clients (and with them the
  only uses of httpx and typing.Any in that module)
- views_api: _get_watchonly_status, GET /events/onchain/status, the
  SatsPay charge branch of api_ticket_create, POST
  /tickets/{id}/satspay-webhook, PUT /tickets/{hash}/onchain-confirm
- models: EventExtra.onchain_{enabled,wallet_id,zeroconf,fasttrack},
  TicketExtra.satspay_charge_id, TicketPaymentRequest.satspay_charge_url
- frontend: the organiser's "Onchain payments" panel and its watchonly
  wallet picker, the per-ticket confirm-onchain button, the SatsPay
  charge-page redirect, and the wallet-status fetch behind them

`onchain` is no longer an accepted payment_method — there is no rail to
fulfil it until #41 lands, so accepting it could only fail later.

Kept as vocabulary for #41, in upstream's field names so it needs no
migration: TicketExtra.onchain / onchain_address and
TicketPaymentRequest.onchain_amount_sat.

35 routes register with no duplicates; ruff, black, prettier clean; 97
tests pass; mypy error set still identical to HEAD's baseline.
This commit is contained in:
Padreug 2026-09-28 22:21:55 +02:00
commit d90a0f8322
6 changed files with 10 additions and 377 deletions

View file

@ -98,14 +98,6 @@ class EventExtra(EventExtraBase):
# `sync_event_ticket_waves`), which is why fork code that reads them # `sync_event_ticket_waves`), which is why fork code that reads them
# needs auditing — aiolabs/events#61. # needs auditing — aiolabs/events#61.
ticket_waves: list[TicketWave] = Field(default_factory=list) ticket_waves: list[TicketWave] = Field(default_factory=list)
# Upstream's SatsPay-backed on-chain config. Removed in the commit
# that follows this merge — we use native lnbits on-chain instead
# (aiolabs/events#41). Kept here so the removal is a legible diff
# against what upstream shipped rather than an invisible omission.
onchain_enabled: bool = False
onchain_wallet_id: str | None = None
onchain_zeroconf: bool = False
onchain_fasttrack: bool = False
PublicEventExtra = EventExtraBase PublicEventExtra = EventExtraBase
@ -256,9 +248,11 @@ class TicketExtra(BaseModel):
email_notification_sent: bool = False email_notification_sent: bool = False
nostr_notification_sent: bool = False nostr_notification_sent: bool = False
refunded: bool = False refunded: bool = False
# On-chain vocabulary, populated once native lnbits on-chain lands
# (aiolabs/events#41). Upstream's field names, minus the SatsPay charge
# id — SatsPay is the implementation we are not adopting.
onchain: bool = False onchain: bool = False
onchain_address: str | None = None onchain_address: str | None = None
satspay_charge_id: str | None = None
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
@ -368,7 +362,6 @@ class TicketPaymentRequest(BaseModel):
# My Tickets. # My Tickets.
ticket_ids: list[str] = Field(default_factory=list) ticket_ids: list[str] = Field(default_factory=list)
onchain_amount_sat: int | None = None onchain_amount_sat: int | None = None
satspay_charge_url: str | None = None
class TicketFilters(FilterModel): class TicketFilters(FilterModel):

View file

@ -9,9 +9,7 @@ from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid from email.utils import formataddr, formatdate, make_msgid
from html import escape from html import escape
from typing import Any
import httpx
from lnbits.core.models.users import UserNotifications from lnbits.core.models.users import UserNotifications
from lnbits.core.services.nostr import send_nostr_dm from lnbits.core.services.nostr import send_nostr_dm
from lnbits.core.services.notifications import send_user_notification from lnbits.core.services.notifications import send_user_notification
@ -45,59 +43,6 @@ from .qr import (
ticket_card_filename, ticket_card_filename,
) )
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 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(
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()
DEFAULT_NOSTR_RELAYS = [ DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io", "wss://relay.damus.io",
"wss://relay.primal.net", "wss://relay.primal.net",

View file

@ -83,19 +83,13 @@ window.PageEventsDisplay = {
}, },
paymentMethodOptions() { paymentMethodOptions() {
// Options come from `paymentMethods` — the same list the backend // Options come from `paymentMethods` — the same list the backend
// validates against in `effective_payment_methods` — rather than being // validates against in `effective_payment_methods` — rather than
// re-derived from per-method booleans. v1.6.8 added a second copy of // being re-derived from per-method booleans, so a rail the server
// this computed that built the list from `allow_fiat`/`onchain_enabled` // would reject can never be offered. The `|| method` fallback covers
// instead; because it was defined later in the object it silently won // a method with no label yet (on-chain, once #41 lands).
// the merge, and it offers a "Bitcoin" option for any event with
// `extra.onchain_enabled` even though the backend rejects `onchain`
// unless it is in `extra.payment_methods` (views_api: "not in
// effective_payment_methods"). Labels below are upstream's; the source
// of the list is ours.
const labels = { const labels = {
lightning: 'Lightning', lightning: 'Lightning',
fiat: this.fiatCheckoutLabel, fiat: this.fiatCheckoutLabel
onchain: 'Bitcoin'
} }
return this.paymentMethods.map(method => ({ return this.paymentMethods.map(method => ({
value: method, value: method,
@ -243,11 +237,6 @@ window.PageEventsDisplay = {
: this.paymentMethods[0] || 'lightning' : this.paymentMethods[0] || 'lightning'
} }
) )
if (data.satspay_charge_url) {
window.location.href = data.satspay_charge_url
return
}
const isFiat = Boolean(data.is_fiat) const isFiat = Boolean(data.is_fiat)
this.paymentReq = isFiat this.paymentReq = isFiat
? data.fiat_payment_request || null ? data.fiat_payment_request || null

View file

@ -272,8 +272,7 @@ window.PageEvents = {
promo_codes: [] promo_codes: []
} }
} }
}, }
onchainWallets: []
} }
}, },
computed: { computed: {
@ -302,42 +301,6 @@ window.PageEvents = {
if (!value) return '' if (!value) return ''
return value.length > 4 ? `${value.slice(0, 4)}...` : value return value.length > 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) { primaryTicketWave(data = this.formDialog.data) {
if (!data.extra) data.extra = {} if (!data.extra) data.extra = {}
if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) { if (!data.extra.ticket_waves || data.extra.ticket_waves.length === 0) {
@ -841,10 +804,6 @@ window.PageEvents = {
min_tickets: 1, min_tickets: 1,
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
onchain_enabled: false,
onchain_wallet_id: null,
onchain_zeroconf: false,
onchain_fasttrack: false,
ticket_waves: [ ticket_waves: [
{ {
id: 'primary', id: 'primary',
@ -867,12 +826,6 @@ window.PageEvents = {
} }
} }
this.formDialog.show = true this.formDialog.show = true
if (
this.formDialog.data.wallet &&
this.formDialog.data.extra?.onchain_enabled
) {
this.loadOnchainWallets()
}
}, },
resetEventDialog() { resetEventDialog() {
this.formDialog.show = false this.formDialog.show = false
@ -888,8 +841,6 @@ window.PageEvents = {
min_tickets: 1, min_tickets: 1,
email_notifications: false, email_notifications: false,
nostr_notifications: false, nostr_notifications: false,
onchain_enabled: false,
onchain_wallet_id: null,
ticket_waves: [ ticket_waves: [
{ {
id: 'primary', id: 'primary',

View file

@ -408,18 +408,6 @@
> >
<q-tooltip>Resend ticket email</q-tooltip> <q-tooltip>Resend ticket email</q-tooltip>
</q-btn> </q-btn>
<q-btn
v-if="props.row.extra?.onchain && !props.row.paid"
flat
dense
size="xs"
@click="confirmOnchainTicket(props.row)"
icon="currency_bitcoin"
color="orange"
class="q-ml-xs"
>
<q-tooltip>Confirm onchain payment</q-tooltip>
</q-btn>
</q-td> </q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props"> <q-td v-for="col in props.cols" :key="col.name" :props="props">
@ -909,55 +897,6 @@
</div> </div>
</q-expansion-item> </q-expansion-item>
<q-expansion-item
group="advanced"
icon="view_in_ar"
label="Onchain payments"
>
<div class="q-mt-lg">
<div class="text-caption q-mb-md">
Accept Bitcoin onchain payments. Requires the
<strong>SatsPay</strong> and <strong>Watchonly</strong>
extensions to be installed and enabled.
</div>
<q-toggle
v-model="formDialog.data.extra.onchain_enabled"
label="Accept onchain payments"
left-label
@update:model-value="val => val && loadOnchainWallets()"
></q-toggle>
<q-select
v-if="formDialog.data.extra.onchain_enabled"
filled
dense
class="q-mt-md"
v-model="formDialog.data.extra.onchain_wallet_id"
label="Watchonly wallet"
:options="
onchainWallets.map(w => ({
label: w.title || w.id,
value: w.id
}))
"
emit-value
map-options
hint="Bitcoin watchonly wallet for receiving onchain payments"
></q-select>
<div v-if="formDialog.data.extra.onchain_enabled" class="q-mt-md">
<q-toggle
v-model="formDialog.data.extra.onchain_zeroconf"
label="Zero-conf (accept unconfirmed transactions)"
left-label
></q-toggle>
<q-toggle
v-model="formDialog.data.extra.onchain_fasttrack"
label="Fasttrack (treat pending as paid)"
left-label
></q-toggle>
</div>
</div>
</q-expansion-item>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="formDialog.data.id" v-if="formDialog.data.id"

View file

@ -38,7 +38,6 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat, satoshis_amount_as_fiat,
) )
from lnbits.utils.nostr import normalize_public_key from lnbits.utils.nostr import normalize_public_key
from loguru import logger
from PIL import Image from PIL import Image
from .crud import ( from .crud import (
@ -99,11 +98,7 @@ from .qr import (
ticket_card_filename, ticket_card_filename,
) )
from .services import ( from .services import (
create_satspay_charge,
event_promo_usage, event_promo_usage,
fetch_watchonly_config,
fetch_watchonly_wallets,
get_satspay_charge,
hydrate_promo_usage, hydrate_promo_usage,
refund_tickets, refund_tickets,
resend_ticket_email_notification, resend_ticket_email_notification,
@ -112,7 +107,6 @@ from .services import (
) )
from .tasks import ( from .tasks import (
deregister_payment_listener, deregister_payment_listener,
payment_listeners,
register_payment_listener, register_payment_listener,
) )
@ -123,35 +117,6 @@ promo_api_router = APIRouter(prefix="/api/v1/promo")
tickets_filters = parse_filters(TicketFilters) tickets_filters = parse_filters(TicketFilters)
async def _get_watchonly_status(wallet) -> 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"),
}
def _is_fiat_currency(currency: str | None) -> bool: def _is_fiat_currency(currency: str | None) -> bool:
return str(currency or "").lower() not in {"sat", "sats"} return str(currency or "").lower() not in {"sat", "sats"}
@ -294,13 +259,6 @@ async def api_get_settings_public(
return {"auto_approve": settings.auto_approve} return {"auto_approve": settings.auto_approve}
@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) @events_api_router.get("/{event_id}", response_model=PublicEvent)
async def api_get_event(event_id: str) -> Event: async def api_get_event(event_id: str) -> Event:
"""Public event detail used by display.vue. """Public event detail used by display.vue.
@ -821,7 +779,7 @@ async def api_ticket_create(
refund_address = data.refund_address refund_address = data.refund_address
nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None nostr_identifier = data.nostr_identifier.strip() if data.nostr_identifier else None
payment_method = (data.payment_method or "lightning").lower() payment_method = (data.payment_method or "lightning").lower()
if payment_method not in {"lightning", "fiat", "onchain"}: if payment_method not in {"lightning", "fiat"}:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.", detail="Unsupported payment method.",
@ -931,7 +889,6 @@ async def api_ticket_create(
invoice_unit = selected_wave.currency invoice_unit = selected_wave.currency
fiat_amount = price fiat_amount = price
fiat_provider = None fiat_provider = None
onchain_amount_sat = None
if payment_method == "fiat": if payment_method == "fiat":
if _is_fiat_currency(selected_wave.currency): if _is_fiat_currency(selected_wave.currency):
@ -956,80 +913,6 @@ async def api_ticket_create(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="No fiat payment provider configured for this event.", detail="No fiat payment provider configured for this event.",
) )
elif payment_method == "onchain":
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.",
)
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("/")
# Use internal address for webhook — SatsPay calls it server-side and
# cannot reach the public domain from within the container.
internal_base = f"http://{settings.host}:{settings.port}"
webhook_url = (
f"{internal_base}/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,
"zeroconf": event.extra.onchain_zeroconf,
"fasttrack": event.extra.onchain_fasttrack,
"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"],
},
)
return TicketPaymentRequest(
payment_hash=ticket_id,
onchain_amount_sat=onchain_amount_sat,
satspay_charge_url=f"/satspay/{charge['id']}",
)
else: else:
invoice_unit = "sat" invoice_unit = "sat"
@ -1194,73 +1077,6 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) await delete_ticket(ticket_id)
@tickets_api_router.post("/{ticket_id}/satspay-webhook")
async def api_ticket_satspay_webhook(ticket_id: str) -> 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(
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 = 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."
)
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,
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.",
)
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}/resend-email", response_model=TicketResendResult) @tickets_api_router.post("/{ticket_id}/resend-email", response_model=TicketResendResult)
async def api_ticket_resend_email( async def api_ticket_resend_email(
ticket_id: str, ticket_id: str,