fix: use satspay for onchain payments (#56)

* use satspay
* safeguard satspay
* add fasttrack and 0conf
This commit is contained in:
dni ⚡ 2026-06-05 12:12:08 +02:00 • committed by GitHub
commit 99b43ff851
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 170 additions and 177 deletions

View file

@ -47,6 +47,8 @@ class EventExtra(BaseModel):
notification_body: str = "" notification_body: str = ""
onchain_enabled: bool = False onchain_enabled: bool = False
onchain_wallet_id: str | None = None onchain_wallet_id: str | None = None
onchain_zeroconf: bool = False
onchain_fasttrack: bool = False
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
@ -113,6 +115,8 @@ class TicketExtra(BaseModel):
nostr_notification_sent: bool = False nostr_notification_sent: bool = False
refunded: bool = False refunded: bool = False
onchain: bool = False onchain: bool = False
onchain_address: str | None = None
satspay_charge_id: str | None = None
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
@ -170,9 +174,8 @@ class TicketPaymentRequest(BaseModel):
fiat_payment_request: str | None = None fiat_payment_request: str | None = None
fiat_provider: str | None = None fiat_provider: str | None = None
is_fiat: bool = False is_fiat: bool = False
onchain_address: str | None = None
onchain_mempool_endpoint: str | None = None
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

@ -62,16 +62,27 @@ async def fetch_watchonly_wallet(api_key: str, wallet_id: str) -> dict[str, Any]
return resp.json() return resp.json()
async def fetch_onchain_address(api_key: str, wallet_id: str) -> dict[str, Any]: async def get_satspay_charge(api_key: str, charge_id: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get( resp = await client.get(
url=f"http://{settings.host}:{settings.port}/watchonly/api/v1/address/{wallet_id}", url=f"http://{settings.host}:{settings.port}/satspay/api/v1/charge/{charge_id}",
headers={"X-API-KEY": api_key}, headers={"X-API-KEY": api_key},
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() 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 set_ticket_paid(ticket: Ticket) -> Ticket: async def set_ticket_paid(ticket: Ticket) -> Ticket:
if ticket.paid: if ticket.paid:
return ticket return ticket

View file

@ -27,11 +27,7 @@ window.PageEventsDisplay = {
show: false, show: false,
status: 'pending', status: 'pending',
paymentReq: null, paymentReq: null,
isFiat: false, isFiat: false
isOnchain: false,
onchainAddress: null,
onchainAmountSat: 0,
mempoolEndpoint: null
}, },
paymentDismissMsg: null, paymentDismissMsg: null,
paymentWebsocket: null paymentWebsocket: null
@ -99,16 +95,6 @@ window.PageEventsDisplay = {
options.push({label: 'Bitcoin', value: 'onchain'}) options.push({label: 'Bitcoin', value: 'onchain'})
} }
return options 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: { methods: {
@ -161,11 +147,7 @@ window.PageEventsDisplay = {
show: false, show: false,
status: 'pending', status: 'pending',
paymentReq: null, paymentReq: null,
isFiat: false, isFiat: false
isOnchain: false,
onchainAddress: null,
onchainAmountSat: 0,
mempoolEndpoint: null
} }
}, },
nameValidation(val) { nameValidation(val) {
@ -203,11 +185,7 @@ window.PageEventsDisplay = {
show: false, show: false,
status: 'complete', status: 'complete',
paymentReq: null, paymentReq: null,
isFiat: false, isFiat: false
isOnchain: false,
onchainAddress: null,
onchainAmountSat: 0,
mempoolEndpoint: null
} }
this.ticketLink = { this.ticketLink = {
show: true, show: true,
@ -235,12 +213,14 @@ window.PageEventsDisplay = {
: 'lightning' : 'lightning'
} }
) )
const isOnchain = Boolean(data.onchain_address) if (data.satspay_charge_url) {
const isFiat = !isOnchain && Boolean(data.is_fiat) window.location.href = data.satspay_charge_url
return
}
const isFiat = Boolean(data.is_fiat)
this.paymentReq = isFiat this.paymentReq = isFiat
? data.fiat_payment_request || null ? data.fiat_payment_request || null
: isOnchain
? null
: data.payment_request : data.payment_request
this.paymentHash = data.payment_hash this.paymentHash = data.payment_hash
@ -252,11 +232,7 @@ window.PageEventsDisplay = {
show: true, show: true,
status: 'pending', status: 'pending',
paymentReq: this.paymentReq, 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) { if (isFiat && this.paymentReq) {
window.open(this.paymentReq, '_blank', 'noopener') window.open(this.paymentReq, '_blank', 'noopener')
@ -291,8 +267,12 @@ window.PageEventsDisplay = {
console.error('WebSocket error:', error) console.error('WebSocket error:', error)
} }
ws.onclose = () => { ws.onclose = () => {
if (this.paymentWebsocket === ws) { if (this.paymentWebsocket !== ws) return
this.paymentWebsocket = null this.paymentWebsocket = null
if (this.receive.show) {
setTimeout(() => {
if (this.receive.show) this.paymentWatcher(paymentHash)
}, 3000)
} }
} }
} }

View file

@ -138,7 +138,7 @@
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog"> <q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card <q-card
v-if="!receive.paymentReq && !receive.isOnchain" v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
</q-card> </q-card>
@ -173,51 +173,6 @@
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn> <q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div> </div>
</q-card> </q-card>
<q-card
v-else-if="receive.isOnchain"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<div class="text-center q-mb-lg">
<div class="text-h6 q-mb-sm">Pay with Bitcoin</div>
<lnbits-qrcode
:href="onchainPaymentUri"
:value="onchainPaymentUri.toUpperCase()"
></lnbits-qrcode>
<div class="text-body1 q-mt-md">
<strong
>{{
(receive.onchainAmountSat / 100000000).toFixed(8)
}}
BTC</strong
>
</div>
<div
class="text-caption text-grey q-mt-xs"
style="word-break: break-all"
>
{{ receive.onchainAddress }}
</div>
</div>
<div class="row q-mt-lg q-col-gutter-sm">
<q-btn
outline
color="grey"
@click="utils.copyText(receive.onchainAddress)"
>Copy address</q-btn
>
<q-btn
v-if="mempoolAddressUrl"
outline
color="grey"
type="a"
:href="mempoolAddressUrl"
target="_blank"
rel="noopener"
>View on mempool</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg"> <div class="text-center q-mb-lg">
<lnbits-qrcode <lnbits-qrcode

View file

@ -442,6 +442,8 @@ window.PageEvents = {
nostr_notifications: false, nostr_notifications: false,
onchain_enabled: false, onchain_enabled: false,
onchain_wallet_id: null, onchain_wallet_id: null,
onchain_zeroconf: false,
onchain_fasttrack: false,
ticket_waves: [ ticket_waves: [
{ {
id: 'primary', id: 'primary',

View file

@ -616,13 +616,14 @@
<q-expansion-item <q-expansion-item
group="advanced" group="advanced"
icon="currency_bitcoin" icon="view_in_ar"
label="Onchain payments" label="Onchain payments"
> >
<div class="q-mt-lg"> <div class="q-mt-lg">
<div class="text-caption q-mb-md"> <div class="text-caption q-mb-md">
Accept Bitcoin onchain payments. Requires the Watchonly Accept Bitcoin onchain payments. Requires the
extension. <strong>SatsPay</strong> and <strong>Watchonly</strong>
extensions to be installed and enabled.
</div> </div>
<q-toggle <q-toggle
v-model="formDialog.data.extra.onchain_enabled" v-model="formDialog.data.extra.onchain_enabled"
@ -647,6 +648,18 @@
map-options map-options
hint="Bitcoin watchonly wallet for receiving onchain payments" hint="Bitcoin watchonly wallet for receiving onchain payments"
></q-select> ></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> </div>
</q-expansion-item> </q-expansion-item>

View file

@ -28,13 +28,14 @@ from lnbits.decorators import (
require_admin_key, require_admin_key,
require_invoice_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.settings import settings
from lnbits.utils.exchange_rates import ( from lnbits.utils.exchange_rates import (
fiat_amount_as_satoshis, fiat_amount_as_satoshis,
get_fiat_rate_satoshis, get_fiat_rate_satoshis,
satoshis_amount_as_fiat, satoshis_amount_as_fiat,
) )
from loguru import logger
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from .crud import ( from .crud import (
@ -66,10 +67,10 @@ from .models import (
get_active_ticket_waves, get_active_ticket_waves,
) )
from .services import ( from .services import (
fetch_onchain_address, create_satspay_charge,
fetch_watchonly_config, fetch_watchonly_config,
fetch_watchonly_wallet,
fetch_watchonly_wallets, fetch_watchonly_wallets,
get_satspay_charge,
refund_tickets, refund_tickets,
resend_ticket_email_notification, resend_ticket_email_notification,
send_ticket_notification_in_background, send_ticket_notification_in_background,
@ -116,47 +117,6 @@ async def _get_watchonly_status(wallet) -> 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: 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"}
@ -528,8 +488,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_address = None
onchain_mempool_endpoint = None
onchain_amount_sat = None onchain_amount_sat = None
if payment_method == "fiat": if payment_method == "fiat":
@ -556,7 +514,6 @@ async def api_ticket_create(
detail="No fiat payment provider configured for this event.", detail="No fiat payment provider configured for this event.",
) )
elif payment_method == "onchain": elif payment_method == "onchain":
invoice_unit = "sat"
onchain_amount_sat = int(price) onchain_amount_sat = int(price)
wallet_record = await get_wallet(event.wallet) wallet_record = await get_wallet(event.wallet)
if not wallet_record: if not wallet_record:
@ -564,33 +521,71 @@ async def api_ticket_create(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
detail="Event wallet does not exist.", detail="Event wallet does not exist.",
) )
validation = await _validate_watchonly_settings( if not event.extra.onchain_enabled:
wallet=wallet_record, raise HTTPException(
onchain_enabled=event.extra.onchain_enabled, status_code=HTTPStatus.BAD_REQUEST,
onchain_wallet_id=event.extra.onchain_wallet_id, detail="Onchain payments are not enabled for this event.",
) )
address_data = await fetch_onchain_address( if not event.extra.onchain_wallet_id:
wallet_record.inkey, event.extra.onchain_wallet_id or "" 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,
"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']}",
) )
onchain_address = address_data.get("address")
onchain_mempool_endpoint = validation.get("mempool_endpoint")
else: else:
invoice_unit = "sat" 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( payment = await create_payment_request(
wallet_id=event.wallet, wallet_id=event.wallet,
invoice_data=CreateInvoice( invoice_data=CreateInvoice(
@ -615,10 +610,8 @@ async def api_ticket_create(
"refund_address": refund_address, "refund_address": refund_address,
"nostr_identifier": nostr_identifier, "nostr_identifier": nostr_identifier,
"ticket_base_url": str(request.base_url).rstrip("/"), "ticket_base_url": str(request.base_url).rstrip("/"),
"sats_paid": ( "sats_paid": payment.sat,
onchain_amount_sat if payment_method == "onchain" else payment.sat "onchain": False,
),
"onchain": payment_method == "onchain",
}, },
) )
@ -628,9 +621,6 @@ async def api_ticket_create(
fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"), fiat_payment_request=getattr(payment, "extra", {}).get("fiat_payment_request"),
fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider, fiat_provider=getattr(payment, "fiat_provider", None) or fiat_provider,
is_fiat=bool(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,
) )
@ -692,6 +682,45 @@ 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, 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(
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") @tickets_api_router.put("/{payment_hash}/onchain-confirm")
async def api_ticket_onchain_confirm( async def api_ticket_onchain_confirm(
payment_hash: str, payment_hash: str,