feat: add currency amount to lnurl/lnaddress payments

closes #2135
This commit is contained in:
dni ⚡ 2023-11-30 09:36:10 +01:00 committed by Pavol Rusnak
parent 8eabf53642
commit 023a1a088e
5 changed files with 132 additions and 90 deletions

View file

@ -368,6 +368,7 @@ class CreateLnurl(BaseModel):
amount: int amount: int
comment: Optional[str] = None comment: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
unit: Optional[str] = None
class CreateInvoice(BaseModel): class CreateInvoice(BaseModel):

View file

@ -35,7 +35,7 @@
> >
<q-card-section> <q-card-section>
<h3 class="q-my-none text-no-wrap"> <h3 class="q-my-none text-no-wrap">
<strong>{% raw %}{{ formattedBalance }} {% endraw %}</strong> <strong v-text="formattedBalance"></strong>
<small>{{LNBITS_DENOMINATION}}</small> <small>{{LNBITS_DENOMINATION}}</small>
<q-btn <q-btn
v-if="'{{user.super_user}}' == 'True'" v-if="'{{user.super_user}}' == 'True'"
@ -150,9 +150,6 @@
@click="exportCSV" @click="exportCSV"
:label="$t('export_csv')" :label="$t('export_csv')"
></q-btn> ></q-btn>
<!--<q-btn v-if="pendingPaymentsExist" dense flat round icon="update" color="grey" @click="checkPendingPayments">
<q-tooltip>Check pending</q-tooltip>
</q-btn>-->
<q-btn <q-btn
dense dense
flat flat
@ -194,13 +191,15 @@
:hide-bottom="mobileSimple" :hide-bottom="mobileSimple"
@request="fetchPayments" @request="fetchPayments"
> >
{% raw %}
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props" <q-th
>{{ col.label }}</q-th v-for="col in props.cols"
> :key="col.name"
:props="props"
v-text="col.label"
></q-th>
</q-tr> </q-tr>
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
@ -219,7 +218,9 @@
color="grey" color="grey"
@click="props.expand = !props.expand" @click="props.expand = !props.expand"
> >
<q-tooltip>{{$t('pending')}}</q-tooltip> <q-tooltip
><span v-text="$t('pending')"></span
></q-tooltip>
</q-icon> </q-icon>
</q-td> </q-td>
<q-td <q-td
@ -232,40 +233,43 @@
color="yellow" color="yellow"
text-color="black" text-color="black"
> >
<a class="inherit" :href="['/', props.row.tag].join('')"> <a
#{{ props.row.tag }} v-text="'#'+props.row.tag"
</a> class="inherit"
:href="['/', props.row.tag].join('')"
></a>
</q-badge> </q-badge>
{{ props.row.memo }} <span v-text="props.row.memo"></span>
<br /> <br />
<i> <i>
{{ props.row.dateFrom }}<q-tooltip <span v-text="props.row.dateFrom"></span>
>{{ props.row.date }}</q-tooltip <q-tooltip
></i ><span v-text="props.row.date"></span
> ></q-tooltip>
</i>
</q-td> </q-td>
{% endraw %}
<q-td <q-td
auto-width auto-width
key="amount" key="amount"
v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
:props="props" :props="props"
>{% raw %} {{ v-text="parseFloat(String(props.row.fsat).replaceAll(',', '')) / 100"
parseFloat(String(props.row.fsat).replaceAll(",", "")) / 100 >
}}
</q-td> </q-td>
<q-td auto-width key="amount" v-else :props="props"> <q-td auto-width key="amount" v-else :props="props">
{{ props.row.fsat }}<br /> <span v-text="props.row.fsat"></span>
<br />
<i v-if="props.row.extra.wallet_fiat_currency"> <i v-if="props.row.extra.wallet_fiat_currency">
{{ formatFiat(props.row.extra.wallet_fiat_currency, <span
props.row.extra.wallet_fiat_amount) }} v-text="formatFiat(props.row.extra.wallet_fiat_currency, props.row.extra.wallet_fiat_amount)"
></span>
<br /> <br />
</i> </i>
<i v-if="props.row.extra.fiat_currency"> <i v-if="props.row.extra.fiat_currency">
{{ formatFiat(props.row.extra.fiat_currency, <span
props.row.extra.fiat_amount) }} v-text="formatFiat(props.row.extra.fiat_currency, props.row.extra.fiat_amount)"
></span>
</i> </i>
</q-td> </q-td>
</q-tr> </q-tr>
@ -340,7 +344,6 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
</template> </template>
{% endraw %}
</q-table> </q-table>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -511,16 +514,15 @@
</div> </div>
<q-dialog v-model="receive.show" position="top"> <q-dialog v-model="receive.show" position="top">
{% raw %}
<q-card <q-card
v-if="!receive.paymentReq" 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-form @submit="createInvoice" class="q-gutter-md"> <q-form @submit="createInvoice" class="q-gutter-md">
<p v-if="receive.lnurl" class="text-h6 text-center q-my-none"> <p v-if="receive.lnurl" class="text-h6 text-center q-my-none">
<b>{{receive.lnurl.domain}}</b> is requesting an invoice: <b v-text="receive.lnurl.domain"></b> is requesting an invoice:
</p> </p>
{% endraw %} {% if LNBITS_DENOMINATION != 'sats' %} {% if LNBITS_DENOMINATION != 'sats' %}
<q-input <q-input
filled filled
dense dense
@ -564,7 +566,6 @@
v-model.trim="receive.data.memo" v-model.trim="receive.data.memo"
:label="$t('memo')" :label="$t('memo')"
></q-input> ></q-input>
{% raw %}
<div v-if="receive.status == 'pending'" class="row q-mt-lg"> <div v-if="receive.status == 'pending'" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -572,9 +573,10 @@
:disable="receive.data.amount == null || receive.data.amount <= 0" :disable="receive.data.amount == null || receive.data.amount <= 0"
type="submit" type="submit"
> >
<span v-if="receive.lnurl"> <span
{{$t('withdraw_from')}} {{receive.lnurl.domain}} v-if="receive.lnurl"
</span> v-text="$t('withdraw_from') + receive.lnurl.domain"
></span>
<span v-else v-text="$t('create_invoice')"></span> <span v-else v-text="$t('create_invoice')"></span>
</q-btn> </q-btn>
<q-btn <q-btn
@ -618,28 +620,32 @@
></q-btn> ></q-btn>
</div> </div>
</q-card> </q-card>
{% endraw %}
</q-dialog> </q-dialog>
<q-dialog v-model="parse.show" @hide="closeParseDialog" position="top"> <q-dialog v-model="parse.show" @hide="closeParseDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card"> <q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div v-if="parse.invoice"> <div v-if="parse.invoice">
<h6 v-if="'{{LNBITS_DENOMINATION}}' != 'sats'" class="q-my-none"> <h6
{% raw %} {{ parseFloat(String(parse.invoice.fsat).replaceAll(",", v-if="'{{LNBITS_DENOMINATION}}' != 'sats'"
"")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} class="q-my-none"
</h6> v-text="parseFloat(String(parse.invoice.fsat).replaceAll(',', '')) / 100 + '{{LNBITS_DENOMINATION}}'"
<h6 v-else class="q-my-none"> ></h6>
{{ parse.invoice.fsat }}{% endraw %} {{LNBITS_DENOMINATION}} {% <h6
raw %} v-else
</h6> class="q-my-none"
v-text="parse.invoice.fsat + '{{LNBITS_DENOMINATION}}'"
></h6>
<q-separator class="q-my-sm"></q-separator> <q-separator class="q-my-sm"></q-separator>
<p class="text-wrap"> <p class="text-wrap">
<strong v-text="$t('memo')">:</strong> {{ <strong v-text="$t('memo') + ': '"></strong>
parse.invoice.description }}<br /> <span v-text="parse.invoice.description"></span>
<strong>Expire date:</strong> {{ parse.invoice.expireDate }}<br /> <br />
<strong>Hash:</strong> {{ parse.invoice.hash }} <strong>Expire date: </strong>
<span v-text="parse.invoice.expireDate"></span>
<br />
<strong>Hash: </strong>
<span v-text="parse.invoice.hash"></span>
</p> </p>
{% endraw %}
<div v-if="canPay" class="row q-mt-lg"> <div v-if="canPay" class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -673,24 +679,31 @@
</div> </div>
</div> </div>
<div v-else-if="parse.lnurlauth"> <div v-else-if="parse.lnurlauth">
{% raw %}
<q-form @submit="authLnurl" class="q-gutter-md"> <q-form @submit="authLnurl" class="q-gutter-md">
<p class="q-my-none text-h6"> <p class="q-my-none text-h6">
Authenticate with <b>{{ parse.lnurlauth.domain }}</b>? Authenticate with <b v-text="parse.lnurlauth.domain"></b>?
</p> </p>
<q-separator class="q-my-sm"></q-separator> <q-separator class="q-my-sm"></q-separator>
<p> <p>
For every website and for every LNbits wallet, a new keypair For every website and for every LNbits wallet, a new keypair
will be deterministically generated so your identity can't be will be deterministically generated so your identity can't be
tied to your LNbits wallet or linked across websites. No other tied to your LNbits wallet or linked across websites. No other
data will be shared with {{ parse.lnurlauth.domain }}. data will be shared with
<span v-text="parse.lnurlauth.domain"></span>.
</p>
<p>
Your public key for <b v-text="parse.lnurlauth.domain"></b> is:
</p> </p>
<p>Your public key for <b>{{ parse.lnurlauth.domain }}</b> is:</p>
<p class="q-mx-xl"> <p class="q-mx-xl">
<code class="text-wrap"> {{ parse.lnurlauth.pubkey }} </code> <code class="text-wrap" v-text="parse.lnurlauth.pubkey"></code>
</p> </p>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Login</q-btn> <q-btn
unelevated
color="primary"
type="submit"
:label="$t('login')"
></q-btn>
<q-btn <q-btn
:label="$t('cancel')" :label="$t('cancel')"
v-close-popup v-close-popup
@ -700,55 +713,74 @@
></q-btn> ></q-btn>
</div> </div>
</q-form> </q-form>
{% endraw %}
</div> </div>
<div v-else-if="parse.lnurlpay"> <div v-else-if="parse.lnurlpay">
{% raw %}
<q-form @submit="payLnurl" class="q-gutter-md"> <q-form @submit="payLnurl" class="q-gutter-md">
<p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6"> <p v-if="parse.lnurlpay.fixed" class="q-my-none text-h6">
<b>{{ parse.lnurlpay.domain }}</b> is requesting {{ <b v-text="parse.lnurlpay.domain"></b> is requesting
parse.lnurlpay.maxSendable | msatoshiFormat }} <span
{{LNBITS_DENOMINATION}} v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"
></span>
<span v-text="'{{LNBITS_DENOMINATION}}'"></span>
<span v-if="parse.lnurlpay.commentAllowed > 0"> <span v-if="parse.lnurlpay.commentAllowed > 0">
<br /> <br />
and a {{parse.lnurlpay.commentAllowed}}-char comment and a
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
comment
</span> </span>
</p> </p>
<p v-else class="q-my-none text-h6 text-center"> <p v-else class="q-my-none text-h6 text-center">
<b>{{ parse.lnurlpay.targetUser || parse.lnurlpay.domain }}</b> <b
v-text="parse.lnurlpay.targetUser || parse.lnurlpay.domain"
></b>
is requesting <br /> is requesting <br />
between between
<b>{{ parse.lnurlpay.minSendable | msatoshiFormat }}</b> and <b v-text="msatoshiFormat(parse.lnurlpay.minSendable)"></b> and
<b>{{ parse.lnurlpay.maxSendable | msatoshiFormat }}</b> <b v-text="msatoshiFormat(parse.lnurlpay.maxSendable)"></b>
{% endraw %} {{LNBITS_DENOMINATION}} {% raw %} <span v-text="'{{LNBITS_DENOMINATION}}'"></span>
<span v-if="parse.lnurlpay.commentAllowed > 0"> <span v-if="parse.lnurlpay.commentAllowed > 0">
<br /> <br />
and a {{parse.lnurlpay.commentAllowed}}-char comment and a
<span v-text="parse.lnurlpay.commentAllowed"></span>-char
comment
</span> </span>
</p> </p>
<q-separator class="q-my-sm"></q-separator> <q-separator class="q-my-sm"></q-separator>
<div class="row"> <div class="row">
<p class="col text-justify text-italic"> <p
{{ parse.lnurlpay.description }} class="col text-justify text-italic"
</p> v-text="parse.lnurlpay.description"
></p>
<p class="col-4 q-pl-md" v-if="parse.lnurlpay.image"> <p class="col-4 q-pl-md" v-if="parse.lnurlpay.image">
<q-img :src="parse.lnurlpay.image" /> <q-img :src="parse.lnurlpay.image" />
</p> </p>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
{% endraw %} <q-select
filled
dense
v-if="!parse.lnurlpay.fixed"
v-model="parse.data.unit"
type="text"
:label="$t('unit')"
:options="receive.units"
></q-select>
<br />
<q-input <q-input
ref="setAmount"
filled filled
dense dense
v-model.number="parse.data.amount" v-model.number="parse.data.amount"
type="number" :label="$t('amount') + ' (' + parse.data.unit + ') *'"
label="Amount ({{LNBITS_DENOMINATION}}) *" :mask="parse.data.unit != 'sat' ? '#.##' : '#'"
:step="parse.data.unit != 'sat' ? '0.01' : '1'"
fill-mask="0"
reverse-fill-mask
:min="parse.lnurlpay.minSendable / 1000" :min="parse.lnurlpay.minSendable / 1000"
:max="parse.lnurlpay.maxSendable / 1000" :max="parse.lnurlpay.maxSendable / 1000"
:readonly="parse.lnurlpay.fixed" :readonly="parse.lnurlpay && parse.lnurlpay.fixed"
></q-input> ></q-input>
{% raw %}
</div> </div>
<div <div
class="col-8 q-pl-md" class="col-8 q-pl-md"
@ -765,9 +797,7 @@
</div> </div>
</div> </div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit" <q-btn unelevated color="primary" type="submit">Send</q-btn>
>Send {{LNBITS_DENOMINATION}}</q-btn
>
<q-btn <q-btn
:label="$t('cancel')" :label="$t('cancel')"
v-close-popup v-close-popup
@ -777,7 +807,6 @@
></q-btn> ></q-btn>
</div> </div>
</q-form> </q-form>
{% endraw %}
</div> </div>
<div v-else> <div v-else>
<q-form <q-form

View file

@ -4,6 +4,7 @@ import json
import uuid import uuid
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO from io import BytesIO
from math import ceil
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
@ -408,9 +409,15 @@ async def api_payments_pay_lnurl(
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client: async with httpx.AsyncClient(headers=headers, follow_redirects=True) as client:
try: try:
if data.unit and data.unit != "sat":
amount_msat = await fiat_amount_as_satoshis(data.amount, data.unit)
# no msat precision
amount_msat = ceil(amount_msat // 1000) * 1000
else:
amount_msat = data.amount
r = await client.get( r = await client.get(
data.callback, data.callback,
params={"amount": data.amount, "comment": data.comment}, params={"amount": amount_msat, "comment": data.comment},
timeout=40, timeout=40,
) )
if r.is_error: if r.is_error:
@ -436,13 +443,13 @@ async def api_payments_pay_lnurl(
) )
invoice = bolt11.decode(params["pr"]) invoice = bolt11.decode(params["pr"])
if invoice.amount_msat != data.amount: if invoice.amount_msat != amount_msat:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=( detail=(
( (
f"{domain} returned an invalid invoice. Expected" f"{domain} returned an invalid invoice. Expected"
f" {data.amount} msat, got {invoice.amount_msat}." f" {amount_msat} msat, got {invoice.amount_msat}."
), ),
), ),
) )
@ -453,6 +460,9 @@ async def api_payments_pay_lnurl(
extra["success_action"] = params["successAction"] extra["success_action"] = params["successAction"]
if data.comment: if data.comment:
extra["comment"] = data.comment extra["comment"] = data.comment
if data.unit and data.unit != "sat":
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required" assert data.description is not None, "description is required"
payment_hash = await pay_invoice( payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id, wallet_id=wallet.wallet.id,

View file

@ -49,14 +49,16 @@ window.LNbits = {
description_hash, description_hash,
amount, amount,
description = '', description = '',
comment = '' comment = '',
unit = ''
) { ) {
return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, { return this.request('post', '/api/v1/payments/lnurl', wallet.adminkey, {
callback, callback,
description_hash, description_hash,
amount, amount,
comment, comment,
description description,
unit
}) })
}, },
authLnurl: function (wallet, callback) { authLnurl: function (wallet, callback) {

View file

@ -113,7 +113,8 @@ new Vue({
data: { data: {
request: '', request: '',
amount: 0, amount: 0,
comment: '' comment: '',
unit: 'sat'
}, },
paymentChecker: null, paymentChecker: null,
copy: { copy: {
@ -286,12 +287,10 @@ new Vue({
return this.payments.findIndex(payment => payment.pending) !== -1 return this.payments.findIndex(payment => payment.pending) !== -1
} }
}, },
filters: { methods: {
msatoshiFormat: function (value) { msatoshiFormat: function (value) {
return LNbits.utils.formatSat(value / 1000) return LNbits.utils.formatSat(value / 1000)
}
}, },
methods: {
paymentTableRowKey: function (row) { paymentTableRowKey: function (row) {
return row.payment_hash + row.amount return row.payment_hash + row.amount
}, },
@ -639,7 +638,8 @@ new Vue({
this.parse.lnurlpay.description_hash, this.parse.lnurlpay.description_hash,
this.parse.data.amount * 1000, this.parse.data.amount * 1000,
this.parse.lnurlpay.description.slice(0, 120), this.parse.lnurlpay.description.slice(0, 120),
this.parse.data.comment this.parse.data.comment,
this.parse.data.unit
) )
.then(response => { .then(response => {
this.parse.show = false this.parse.show = false