Rebase onto upstream v1.6.8 #63

Merged
padreug merged 10 commits from rebase/upstream-v1.6.8 into main 2026-09-28 22:02:55 +00:00
7 changed files with 423 additions and 33 deletions
Showing only changes of commit e0ea0e30e7 - Show all commits

feat: onchain payments (#53)

* feat: onchain payments
dni ⚡ 2026-06-03 10:51:15 +02:00 • committed by GitHub
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -45,6 +45,8 @@ class EventExtra(BaseModel):
nostr_notifications: bool = False nostr_notifications: bool = False
notification_subject: str = "" notification_subject: str = ""
notification_body: str = "" notification_body: str = ""
onchain_enabled: bool = False
onchain_wallet_id: str | None = None
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
@ -110,6 +112,7 @@ 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
onchain: bool = False
class CreateTicket(BaseModel): class CreateTicket(BaseModel):
@ -167,6 +170,9 @@ 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
class TicketFilters(FilterModel): class TicketFilters(FilterModel):

View file

@ -5,7 +5,9 @@ from asyncio.tasks import create_task
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
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.notifications import send_user_notification from lnbits.core.services.notifications import send_user_notification
from lnbits.helpers import is_valid_email_address from lnbits.helpers import is_valid_email_address
@ -29,6 +31,47 @@ from .models import (
) )
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: async def set_ticket_paid(ticket: Ticket) -> Ticket:
if ticket.paid: if ticket.paid:
return ticket return ticket

View file

@ -27,7 +27,11 @@ 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
@ -79,6 +83,32 @@ window.PageEventsDisplay = {
}, },
allowNostrNotifications() { allowNostrNotifications() {
return Boolean(this.event?.extra?.nostr_notifications) 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: { methods: {
@ -131,7 +161,11 @@ 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) {
@ -169,7 +203,11 @@ 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,
@ -192,15 +230,18 @@ window.PageEventsDisplay = {
promo_code: this.formDialog.data.promo_code || null, promo_code: this.formDialog.data.promo_code || null,
refund_address: this.formDialog.data.refund || null, refund_address: this.formDialog.data.refund || null,
nostr_identifier: this.formDialog.data.nostr_identifier || null, nostr_identifier: this.formDialog.data.nostr_identifier || null,
payment_method: this.allowFiatCheckout payment_method: this.showPaymentMethodSelector
? this.formDialog.data.payment_method ? this.formDialog.data.payment_method
: 'lightning' : 'lightning'
} }
) )
const isFiat = Boolean(data.is_fiat) const isOnchain = Boolean(data.onchain_address)
const isFiat = !isOnchain && Boolean(data.is_fiat)
this.paymentReq = isFiat this.paymentReq = isFiat
? data.fiat_payment_request || null ? data.fiat_payment_request || null
: data.payment_request : isOnchain
? null
: data.payment_request
this.paymentHash = data.payment_hash this.paymentHash = data.payment_hash
this.paymentDismissMsg = Quasar.Notify.create({ this.paymentDismissMsg = Quasar.Notify.create({
@ -211,7 +252,11 @@ 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')

View file

@ -80,20 +80,18 @@
" "
></q-select> ></q-select>
<div class="row q-col-gutter-md q-pt-lg items-center"> <div class="row q-col-gutter-md q-pt-lg items-center">
<div v-if="allowFiatCheckout" class="col-auto"> <div v-if="showPaymentMethodSelector" class="col-auto">
<q-option-group <q-option-group
v-model="formDialog.data.payment_method" v-model="formDialog.data.payment_method"
inline inline
:options="[ :options="paymentMethodOptions"
{label: 'Lightning', value: 'lightning'},
{
label: fiatCheckoutLabel,
value: 'fiat'
}
]"
></q-option-group> ></q-option-group>
</div> </div>
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'"> <div
:class="
showPaymentMethodSelector ? 'col-12 col-md-3' : 'col-12'
"
>
<q-input <q-input
filled filled
dense dense
@ -140,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" v-if="!receive.paymentReq && !receive.isOnchain"
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
</q-card> </q-card>
@ -175,6 +173,51 @@
<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

@ -128,7 +128,8 @@ window.PageEvents = {
promo_codes: [] promo_codes: []
} }
} }
} },
onchainWallets: []
} }
}, },
methods: { methods: {
@ -136,6 +137,42 @@ 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) {
@ -403,6 +440,8 @@ 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',
@ -425,6 +464,12 @@ 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
@ -437,6 +482,8 @@ 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

@ -265,6 +265,18 @@
> >
<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">
@ -602,6 +614,42 @@
</div> </div>
</q-expansion-item> </q-expansion-item>
<q-expansion-item
group="advanced"
icon="currency_bitcoin"
label="Onchain payments"
>
<div class="q-mt-lg">
<div class="text-caption q-mb-md">
Accept Bitcoin onchain payments. Requires the Watchonly
extension.
</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>
</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

@ -16,7 +16,7 @@ from fastapi import (
WebSocketDisconnect, WebSocketDisconnect,
) )
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from lnbits.core.crud import get_user from lnbits.core.crud import get_standalone_payment, get_user
from lnbits.core.crud.assets import get_public_asset from lnbits.core.crud.assets import get_public_asset
from lnbits.core.crud.wallets import get_wallet from lnbits.core.crud.wallets import get_wallet
from lnbits.core.models import WalletTypeInfo from lnbits.core.models import WalletTypeInfo
@ -30,6 +30,7 @@ from lnbits.decorators import (
) )
from lnbits.helpers import generate_filter_params_openapi from lnbits.helpers import generate_filter_params_openapi
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import internal_invoice_queue_put
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,
@ -65,7 +66,14 @@ from .models import (
ensure_ticket_waves, ensure_ticket_waves,
get_active_ticket_waves, get_active_ticket_waves,
) )
from .services import refund_tickets, resend_ticket_email_notification from .services import (
fetch_onchain_address,
fetch_watchonly_config,
fetch_watchonly_wallet,
fetch_watchonly_wallets,
refund_tickets,
resend_ticket_email_notification,
)
from .tasks import deregister_payment_listener, register_payment_listener from .tasks import deregister_payment_listener, register_payment_listener
events_api_router = APIRouter(prefix="/api/v1/events") events_api_router = APIRouter(prefix="/api/v1/events")
@ -74,6 +82,76 @@ qr_api_router = APIRouter(prefix="/api/v1")
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"),
}
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"}
@ -120,6 +198,13 @@ async def api_events(
return await get_events(wallet_ids) 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) @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:
event = await get_event(event_id) event = await get_event(event_id)
@ -371,7 +456,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"}: if payment_method not in {"lightning", "fiat", "onchain"}:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="Unsupported payment method.", detail="Unsupported payment method.",
@ -438,6 +523,10 @@ 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
if payment_method == "fiat": if payment_method == "fiat":
if _is_fiat_currency(selected_wave.currency): if _is_fiat_currency(selected_wave.currency):
invoice_unit = selected_wave.currency invoice_unit = selected_wave.currency
@ -461,20 +550,53 @@ 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":
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: else:
invoice_unit = "sat" invoice_unit = "sat"
payment = await create_payment_request( if payment_method == "onchain":
wallet_id=event.wallet, payment = await create_payment_request(
invoice_data=CreateInvoice( wallet_id=event.wallet,
out=False, invoice_data=CreateInvoice(
amount=fiat_amount if payment_method == "fiat" else price, out=False,
unit=invoice_unit, amount=float(onchain_amount_sat or 0),
fiat_provider=fiat_provider, unit="sat",
memo=f"{event_id}", internal=True,
extra=extra, 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( await create_ticket(
payment_hash=payment.payment_hash, payment_hash=payment.payment_hash,
wallet=event.wallet, wallet=event.wallet,
@ -488,7 +610,10 @@ 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": payment.sat, "sats_paid": (
onchain_amount_sat if payment_method == "onchain" else payment.sat
),
"onchain": payment_method == "onchain",
}, },
) )
@ -498,6 +623,9 @@ 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,
) )
@ -559,6 +687,36 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) 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(payment_hash)
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,