feat: onchain payments (#53)

* feat: onchain payments
This commit is contained in:
dni ⚡ 2026-06-03 10:51:15 +02:00 • committed by GitHub
commit e0ea0e30e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 423 additions and 33 deletions

View file

@ -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,32 @@ 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 +161,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 +203,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 +230,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 +252,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')

View file

@ -80,20 +80,18 @@
"
></q-select>
<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
v-model="formDialog.data.payment_method"
inline
:options="[
{label: 'Lightning', value: 'lightning'},
{
label: fiatCheckoutLabel,
value: 'fiat'
}
]"
:options="paymentMethodOptions"
></q-option-group>
</div>
<div :class="allowFiatCheckout ? 'col-12 col-md-3' : 'col-12'">
<div
:class="
showPaymentMethodSelector ? 'col-12 col-md-3' : 'col-12'
"
>
<q-input
filled
dense
@ -140,7 +138,7 @@
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card
v-if="!receive.paymentReq"
v-if="!receive.paymentReq && !receive.isOnchain"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
@ -175,6 +173,51 @@
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</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">
<div class="text-center q-mb-lg">
<lnbits-qrcode

View file

@ -128,7 +128,8 @@ window.PageEvents = {
promo_codes: []
}
}
}
},
onchainWallets: []
}
},
methods: {
@ -136,6 +137,42 @@ window.PageEvents = {
if (!value) return ''
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) {
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',

View file

@ -265,6 +265,18 @@
>
<q-tooltip>Resend ticket email</q-tooltip>
</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 v-for="col in props.cols" :key="col.name" :props="props">
@ -602,6 +614,42 @@
</div>
</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">
<q-btn
v-if="formDialog.data.id"