Merge pull request #43 from fiatjaf/lnurlpayserver

description_hash support, spark backend and lnurlp extension.
This commit is contained in:
fiatjaf 2020-08-29 14:02:59 -03:00 committed by GitHub
commit 479760c5a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1136 additions and 60 deletions

View File

@ -21,6 +21,11 @@ Using this wallet requires the installation of the `pylightning` Python package.
- `LNBITS_BACKEND_WALLET_CLASS`: **CLightningWallet**
- `CLIGHTNING_RPC`: /file/path/lightning-rpc
### Spark (c-lightning)
- `LNBITS_BACKEND_WALLET_CLASS`: **SparkWallet**
- `SPARK_URL`: http://10.147.17.230:9737/rpc
- `SPARK_TOKEN`: secret_access_key
### LND (gRPC)

View File

@ -2,7 +2,6 @@
import bitstring
import re
from binascii import hexlify
from bech32 import bech32_decode, CHARSET
@ -51,9 +50,9 @@ def decode(pr: str) -> Invoice:
if tag == "d":
invoice.description = trim_to_bytes(tagdata).decode("utf-8")
elif tag == "h" and data_length == 52:
invoice.description = hexlify(trim_to_bytes(tagdata)).decode("ascii")
invoice.description = trim_to_bytes(tagdata).hex()
elif tag == "p" and data_length == 52:
invoice.payment_hash = hexlify(trim_to_bytes(tagdata)).decode("ascii")
invoice.payment_hash = trim_to_bytes(tagdata).hex()
return invoice

View File

@ -115,7 +115,7 @@ def get_wallet(wallet_id: str) -> Optional[Wallet]:
def get_wallet_for_key(key: str, key_type: str = "invoice") -> Optional[Wallet]:
with open_db() as db:
row = db.fetchone(
f"""
"""
SELECT *, COALESCE((SELECT balance FROM balances WHERE wallet = wallets.id), 0) AS balance_msat
FROM wallets
WHERE adminkey = ? OR inkey = ?

View File

@ -1,15 +1,18 @@
from typing import Optional, Tuple
from lnbits.bolt11 import decode as bolt11_decode # type: ignore
from lnbits.bolt11 import decode as bolt11_decode # type: ignore
from lnbits.helpers import urlsafe_short_hash
from lnbits.settings import WALLET
from .crud import get_wallet, create_payment, delete_payment
def create_invoice(*, wallet_id: str, amount: int, memo: str) -> Tuple[str, str]:
def create_invoice(*, wallet_id: str, amount: int, memo: str, description_hash: bytes) -> Tuple[str, str]:
try:
ok, checking_id, payment_request, error_message = WALLET.create_invoice(amount=amount, memo=memo)
ok, checking_id, payment_request, error_message = WALLET.create_invoice(
amount=amount, memo=memo, description_hash=description_hash
)
except Exception as e:
ok, error_message = False, str(e)
@ -35,11 +38,7 @@ def pay_invoice(*, wallet_id: str, bolt11: str, max_sat: Optional[int] = None) -
fee_reserve = max(1000, int(invoice.amount_msat * 0.01))
create_payment(
wallet_id=wallet_id,
checking_id=temp_id,
amount=-invoice.amount_msat,
fee=-fee_reserve,
memo=temp_id,
wallet_id=wallet_id, checking_id=temp_id, amount=-invoice.amount_msat, fee=-fee_reserve, memo=temp_id,
)
wallet = get_wallet(wallet_id)

View File

@ -1,5 +1,6 @@
from flask import g, jsonify, request
from http import HTTPStatus
from binascii import unhexlify
from lnbits.core import core_app
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
@ -27,13 +28,21 @@ def api_payments():
@api_validate_post_request(
schema={
"amount": {"type": "integer", "min": 1, "required": True},
"memo": {"type": "string", "empty": False, "required": True},
"memo": {"type": "string", "empty": False, "required": True, "excludes": "description_hash"},
"description_hash": {"type": "string", "empty": False, "required": True, "excludes": "memo"},
}
)
def api_payments_create_invoice():
if "description_hash" in g.data:
description_hash = unhexlify(g.data["description_hash"])
memo = ""
else:
description_hash = b""
memo = g.data["memo"]
try:
checking_id, payment_request = create_invoice(
wallet_id=g.wallet.id, amount=g.data["amount"], memo=g.data["memo"]
wallet_id=g.wallet.id, amount=g.data["amount"], memo=memo, description_hash=description_hash
)
except Exception as e:
return jsonify({"message": str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR

View File

@ -36,7 +36,7 @@ def api_validate_post_request(*, schema: dict):
return jsonify({"message": "Content-Type must be `application/json`."}), HTTPStatus.BAD_REQUEST
v = Validator(schema)
g.data = {key: (request.json[key] if key in request.json else None) for key in schema.keys()}
g.data = {key: request.json[key] for key in schema.keys() if key in request.json}
if not v.validate(g.data):
return jsonify({"message": f"Errors in request data: {v.errors}"}), HTTPStatus.BAD_REQUEST
@ -56,7 +56,7 @@ def check_user_exists(param: str = "usr"):
allowed_users = getenv("LNBITS_ALLOWED_USERS", "all")
if allowed_users != "all" and g.user.id not in allowed_users.split(","):
abort(HTTPStatus.UNAUTHORIZED, f"User not authorized.")
abort(HTTPStatus.UNAUTHORIZED, "User not authorized.")
return view(**kwargs)

View File

@ -0,0 +1 @@
# LNURLp

View File

@ -0,0 +1,8 @@
from flask import Blueprint
lnurlp_ext: Blueprint = Blueprint("lnurlp", __name__, static_folder="static", template_folder="templates")
from .views_api import * # noqa
from .views import * # noqa

View File

@ -0,0 +1,10 @@
{
"name": "LNURLp",
"short_description": "Make reusable LNURL pay links",
"icon": "receipt",
"contributors": [
"arcbtc",
"eillarra",
"fiatjaf"
]
}

View File

@ -0,0 +1,74 @@
from typing import List, Optional, Union
from lnbits.db import open_ext_db
from .models import PayLink
def create_pay_link(*, wallet_id: str, description: str, amount: int) -> PayLink:
with open_ext_db("lnurlp") as db:
with db.cursor() as c:
c.execute(
"""
INSERT INTO pay_links (
wallet,
description,
amount,
served_meta,
served_pr
)
VALUES (?, ?, ?, 0, 0)
""",
(wallet_id, description, amount),
)
return get_pay_link(c.lastrowid)
def get_pay_link(link_id: str) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db:
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def get_pay_link_by_hash(unique_hash: str) -> Optional[PayLink]:
with open_ext_db("lnurlp") as db:
row = db.fetchone("SELECT * FROM pay_links WHERE unique_hash = ?", (unique_hash,))
return PayLink.from_row(row) if row else None
def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
with open_ext_db("lnurlp") as db:
q = ",".join(["?"] * len(wallet_ids))
rows = db.fetchall(f"SELECT * FROM pay_links WHERE wallet IN ({q})", (*wallet_ids,))
return [PayLink.from_row(row) for row in rows]
def update_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db:
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def increment_pay_link(link_id: str, **kwargs) -> Optional[PayLink]:
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
with open_ext_db("lnurlp") as db:
db.execute(f"UPDATE pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id))
row = db.fetchone("SELECT * FROM pay_links WHERE id = ?", (link_id,))
return PayLink.from_row(row) if row else None
def delete_pay_link(link_id: str) -> None:
with open_ext_db("lnurlp") as db:
db.execute("DELETE FROM pay_links WHERE id = ?", (link_id,))

View File

@ -0,0 +1,24 @@
from lnbits.db import open_ext_db
def m001_initial(db):
"""
Initial pay table.
"""
db.execute(
"""
CREATE TABLE IF NOT EXISTS pay_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet TEXT NOT NULL,
description TEXT NOT NULL,
amount INTEGER NOT NULL,
served_meta INTEGER NOT NULL,
served_pr INTEGER NOT NULL
);
"""
)
def migrate():
with open_ext_db("lnurlp") as db:
m001_initial(db)

View File

@ -0,0 +1,32 @@
import json
from flask import url_for
from lnurl import Lnurl, encode as lnurl_encode
from lnurl.types import LnurlPayMetadata
from sqlite3 import Row
from typing import NamedTuple
from lnbits.settings import FORCE_HTTPS
class PayLink(NamedTuple):
id: str
wallet: str
description: str
amount: int
served_meta: int
served_pr: int
@classmethod
def from_row(cls, row: Row) -> "PayLink":
data = dict(row)
return cls(**data)
@property
def lnurl(self) -> Lnurl:
scheme = "https" if FORCE_HTTPS else None
url = url_for("lnurlp.api_lnurl_response", link_id=self.id, _external=True, _scheme=scheme)
return lnurl_encode(url)
@property
def lnurlpay_metadata(self) -> LnurlPayMetadata:
return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))

View File

@ -0,0 +1,130 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item group="api" dense expand-separator label="List pay links">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /pay/api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;pay_link_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}pay/api/v1/links -H "X-Api-Key: {{
g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get a pay link">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/pay/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt; -H
"X-Api-Key: {{ g.user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create a pay link"
>
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /pay/api/v1/links</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"description": &lt;string&gt; "amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.url_root }}pay/api/v1/links -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Update a pay link"
>
<q-card>
<q-card-section>
<code
><span class="text-green">PUT</span>
/pay/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code>{"description": &lt;string&gt;, "amount": &lt;integer&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>{"lnurl": &lt;string&gt;}</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X PUT {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt; -d
'{"description": &lt;string&gt;, "amount": &lt;integer&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: {{
g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/pay/api/v1/links/&lt;pay_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.url_root }}pay/api/v1/links/&lt;pay_id&gt;
-H "X-Api-Key: {{ g.user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,28 @@
<q-expansion-item group="extras" icon="info" label="Powered by LNURL">
<q-card>
<q-card-section>
<p>
<b>WARNING: LNURL must be used over https or TOR</b><br />
LNURL is a range of lightning-network standards that allow us to use
lightning-network differently. An LNURL-pay is a link that wallets use
to fetch an invoice from a server on-demand. The link or QR code is
fixed, but each time it is read by a compatible wallet a new QR code is
issued by the service. It can be used to activate machines without them
having to maintain an electronic screen to generate and show invoices
locally, or to sell any predefined good or service automatically.
</p>
<p>
Exploring LNURL and finding use cases, is really helping inform
lightning protocol development, rather than the protocol dictating how
lightning-network should be engaged with.
</p>
<small
>Check
<a href="https://github.com/fiatjaf/awesome-lnurl" target="_blank"
>Awesome LNURL</a
>
for further information.</small
>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -0,0 +1,54 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-sm-6 col-md-5 col-lg-4">
<q-card class="q-pa-lg">
<q-card-section class="q-pa-none">
<div class="text-center">
<a href="lightning:{{ link.lnurl }}">
<q-responsive :ratio="1" class="q-mx-md">
<qrcode
value="{{ link.lnurl }}"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText('{{ link.lnurl }}')"
>Copy LNURL</q-btn
>
</div>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-sm-6 col-md-5 col-lg-4 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-mb-sm q-mt-none">
LNbits LNURL-pay link
</h6>
<p class="q-my-none">
Use a LNURL compatible bitcoin wallet to claim the sats.
</p>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lnurlp/_lnurl.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin]
})
</script>
{% endblock %}

View File

@ -0,0 +1,401 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="deep-purple" @click="formDialog.show = true"
>New pay link</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Pay links</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="payLinks"
row-key="id"
:columns="payLinksTable.columns"
:pagination.sync="payLinksTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="launch"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.pay_url"
target="_blank"
></q-btn>
<q-btn
unelevated
dense
size="xs"
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="openUpdateDialog(props.row.id)"
icon="edit"
color="light-blue"
></q-btn>
<q-btn
flat
dense
size="xs"
@click="deletePayLink(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
LNbits LNURL-pay extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lnurlp/_api_docs.html" %}
<q-separator></q-separator>
{% include "lnurlp/_lnurl.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
v-model.trim="formDialog.data.description"
type="text"
label="Item description *"
></q-input>
<q-input
filled
dense
v-model.number="formDialog.data.amount"
type="number"
label="Amount (sat) *"
></q-input>
<div class="row q-mt-lg">
<q-btn
v-if="formDialog.data.id"
unelevated
color="deep-purple"
type="submit"
>Update pay link</q-btn
>
<q-btn
v-else
unelevated
color="deep-purple"
:disable="
formDialog.data.wallet == null ||
formDialog.data.description == null ||
(
formDialog.data.amount == null ||
formDialog.data.amount < 1
)"
type="submit"
>Create pay link</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
{% raw %}
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
</q-responsive>
<p style="word-break: break-all;">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<br />
<strong>Amount:</strong> {{ qrCodeDialog.data.amount }} sat<br />
</p>
{% endraw %}
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.lnurl, 'LNURL copied to clipboard!')"
class="q-ml-sm"
>Copy LNURL</q-btn
>
<q-btn
outline
color="grey"
@click="copyText(qrCodeDialog.data.pay_url, 'Link copied to clipboard!')"
>Shareable link</q-btn
>
<q-btn
v-if="!qrCodeDialog.data.is_unique"
outline
color="grey"
icon="print"
type="a"
:href="qrCodeDialog.data.print_url"
target="_blank"
></q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
var locationPath = [
window.location.protocol,
'//',
window.location.hostname,
window.location.pathname
].join('')
var mapPayLink = function (obj) {
obj._data = _.clone(obj)
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.amount = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.print_url = [locationPath, 'print/', obj.id].join('')
obj.pay_url = [locationPath, obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
checker: null,
payLinks: [],
payLinksTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'amount',
align: 'right',
label: 'Amount (sat)',
field: 'amount'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
secondMultiplier: 'seconds',
secondMultiplierOptions: ['seconds', 'minutes', 'hours'],
data: {
is_unique: false
}
},
qrCodeDialog: {
show: false,
data: null
}
}
},
methods: {
getPayLinks: function () {
var self = this
LNbits.api
.request(
'GET',
'/lnurlp/api/v1/links?all_wallets',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.payLinks = response.data.map(function (obj) {
return mapPayLink(obj)
})
})
.catch(function (error) {
clearInterval(self.checker)
LNbits.utils.notifyApiError(error)
})
},
closeFormDialog: function () {
this.formDialog.data = {
is_unique: false
}
},
openQrCodeDialog: function (linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
this.qrCodeDialog.data = _.clone(link)
this.qrCodeDialog.show = true
},
openUpdateDialog: function (linkId) {
var link = _.findWhere(this.payLinks, {id: linkId})
this.formDialog.data = _.clone(link._data)
this.formDialog.show = true
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet
})
var data = _.omit(this.formDialog.data, 'wallet')
data.wait_time =
data.wait_time *
{
seconds: 1,
minutes: 60,
hours: 3600
}[this.formDialog.secondMultiplier]
if (data.id) {
this.updatePayLink(wallet, data)
} else {
this.createPayLink(wallet, data)
}
},
updatePayLink: function (wallet, data) {
var self = this
LNbits.api
.request(
'PUT',
'/lnurlp/api/v1/links/' + data.id,
wallet.adminkey,
_.pick(data, 'description', 'amount')
)
.then(function (response) {
self.payLinks = _.reject(self.payLinks, function (obj) {
return obj.id === data.id
})
self.payLinks.push(mapPayLink(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
createPayLink: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
.then(function (response) {
self.payLinks.push(mapPayLink(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deletePayLink: function (linkId) {
var self = this
var link = _.findWhere(this.payLinks, {id: linkId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/lnurlp/api/v1/links/' + linkId,
_.findWhere(self.g.user.wallets, {id: link.wallet}).adminkey
)
.then(function (response) {
self.payLinks = _.reject(self.payLinks, function (obj) {
return obj.id === linkId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.paywallsTable.columns, this.paywalls)
}
},
created: function () {
if (this.g.user.wallets.length) {
var getPayLinks = this.getPayLinks
getPayLinks()
this.checker = setInterval(function () {
getPayLinks()
}, 20000)
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,28 @@
{% extends "print.html" %} {% block page %}
<div class="row justify-center">
<div class="qr">
<qrcode value="{{ link.lnurl }}" :options="{width}"></qrcode>
</div>
</div>
{% endblock %} {% block styles %}
<style>
.qr {
margin: auto;
}
</style>
{% endblock %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/vue-qrcode@1.0.2/vue-qrcode.min.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
created: function () {
window.print()
},
data: function () {
return {width: window.innerWidth * 0.5}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,28 @@
from flask import g, abort, render_template
from http import HTTPStatus
from lnbits.decorators import check_user_exists, validate_uuids
from lnbits.extensions.lnurlp import lnurlp_ext
from .crud import get_pay_link
@lnurlp_ext.route("/")
@validate_uuids(["usr"], required=True)
@check_user_exists()
def index():
return render_template("lnurlp/index.html", user=g.user)
@lnurlp_ext.route("/<link_id>")
def display(link_id):
link = get_pay_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
return render_template("lnurlp/display.html", link=link)
@lnurlp_ext.route("/print/<link_id>")
def print_qr(link_id):
link = get_pay_link(link_id) or abort(HTTPStatus.NOT_FOUND, "Pay link does not exist.")
return render_template("lnurlp/print_qr.html", link=link)

View File

@ -0,0 +1,129 @@
import hashlib
from flask import g, jsonify, request, url_for
from http import HTTPStatus
from lnurl import LnurlPayResponse, LnurlPayActionResponse
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from lnbits.core.crud import get_user
from lnbits.core.services import create_invoice
from lnbits.decorators import api_check_wallet_key, api_validate_post_request
from lnbits.settings import FORCE_HTTPS
from lnbits.extensions.lnurlp import lnurlp_ext
from .crud import (
create_pay_link,
get_pay_link,
get_pay_links,
update_pay_link,
increment_pay_link,
delete_pay_link,
)
@lnurlp_ext.route("/api/v1/links", methods=["GET"])
@api_check_wallet_key("invoice")
def api_links():
wallet_ids = [g.wallet.id]
if "all_wallets" in request.args:
wallet_ids = get_user(g.wallet.user).wallet_ids
try:
return (
jsonify([{**link._asdict(), **{"lnurl": link.lnurl}} for link in get_pay_links(wallet_ids)]),
HTTPStatus.OK,
)
except LnurlInvalidUrl:
return (
jsonify({"message": "LNURLs need to be delivered over a publically accessible `https` domain or Tor."}),
HTTPStatus.UPGRADE_REQUIRED,
)
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["GET"])
@api_check_wallet_key("invoice")
def api_link_retrieve(link_id):
link = get_pay_link(link_id)
if not link:
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK
@lnurlp_ext.route("/api/v1/links", methods=["POST"])
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["PUT"])
@api_check_wallet_key("invoice")
@api_validate_post_request(
schema={
"description": {"type": "string", "empty": False, "required": True},
"amount": {"type": "integer", "min": 1, "required": True},
}
)
def api_link_create_or_update(link_id=None):
if link_id:
link = get_pay_link(link_id)
if not link:
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
link = update_pay_link(link_id, **g.data)
else:
link = create_pay_link(wallet_id=g.wallet.id, **g.data)
return jsonify({**link._asdict(), **{"lnurl": link.lnurl}}), HTTPStatus.OK if link_id else HTTPStatus.CREATED
@lnurlp_ext.route("/api/v1/links/<link_id>", methods=["DELETE"])
@api_check_wallet_key("invoice")
def api_link_delete(link_id):
link = get_pay_link(link_id)
if not link:
return jsonify({"message": "Pay link does not exist."}), HTTPStatus.NOT_FOUND
if link.wallet != g.wallet.id:
return jsonify({"message": "Not your pay link."}), HTTPStatus.FORBIDDEN
delete_pay_link(link_id)
return "", HTTPStatus.NO_CONTENT
@lnurlp_ext.route("/api/v1/lnurl/<link_id>", methods=["GET"])
def api_lnurl_response(link_id):
link = increment_pay_link(link_id, served_meta=1)
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
scheme = "https" if FORCE_HTTPS else None
url = url_for("lnurlp.api_lnurl_callback", link_id=link.id, _external=True, _scheme=scheme)
resp = LnurlPayResponse(
callback=url, min_sendable=link.amount * 1000, max_sendable=link.amount * 1000, metadata=link.lnurlpay_metadata,
)
return jsonify(resp.dict()), HTTPStatus.OK
@lnurlp_ext.route("/api/v1/lnurl/cb/<link_id>", methods=["GET"])
def api_lnurl_callback(link_id):
link = increment_pay_link(link_id, served_pr=1)
if not link:
return jsonify({"status": "ERROR", "reason": "LNURL-pay not found."}), HTTPStatus.OK
_, payment_request = create_invoice(
wallet_id=link.wallet,
amount=link.amount,
memo=link.description,
description_hash=hashlib.sha256(link.lnurlpay_metadata.encode("utf-8")).digest(),
)
resp = LnurlPayActionResponse(pr=payment_request, success_action=None, routes=[])
return jsonify(resp.dict()), HTTPStatus.OK

View File

@ -7,3 +7,4 @@ from .opennode import OpenNodeWallet
from .lnpay import LNPayWallet
from .lnbits import LNbitsWallet
from .lndrest import LndRestWallet
from .spark import SparkWallet

View File

@ -26,7 +26,7 @@ class PaymentStatus(NamedTuple):
class Wallet(ABC):
@abstractmethod
def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse:
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
pass
@abstractmethod
@ -40,3 +40,7 @@ class Wallet(ABC):
@abstractmethod
def get_payment_status(self, checking_id: str) -> PaymentStatus:
pass
class Unsupported(Exception):
pass

View File

@ -7,39 +7,47 @@ import random
from os import getenv
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
class CLightningWallet(Wallet):
def __init__(self):
if LightningRpc is None: # pragma: nocover
raise ImportError("The `pylightning` library must be installed to use `CLightningWallet`.")
self.l1 = LightningRpc(getenv("CLIGHTNING_RPC"))
def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse:
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
if description_hash:
raise Unsupported("description_hash")
label = "lbl{}".format(random.random())
r = self.l1.invoice(amount*1000, label, memo, exposeprivatechannels=True)
r = self.l1.invoice(amount * 1000, label, memo, exposeprivatechannels=True)
ok, checking_id, payment_request, error_message = True, r["payment_hash"], r["bolt11"], None
return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = self.l1.pay(bolt11)
ok, checking_id, fee_msat, error_message = True, None, 0, None
ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None
return PaymentResponse(ok, checking_id, fee_msat, error_message)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = self.l1.listinvoices(checking_id)
if r['invoices'][0]['status'] == 'unpaid':
if not r["invoices"]:
return PaymentStatus(False)
return PaymentStatus(True)
if r["invoices"][0]["label"] == checking_id:
return PaymentStatus(r["pays"][0]["status"] == "paid")
raise KeyError("supplied an invalid checking_id")
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = self.l1.listsendpays(checking_id)
if not r.ok:
r = self.l1.listpays(payment_hash=checking_id)
if not r["pays"]:
return PaymentStatus(False)
if r["pays"][0]["payment_hash"] == checking_id:
status = r["pays"][0]["status"]
if status == "complete":
return PaymentStatus(True)
elif status == "failed":
return PaymentStatus(False)
return PaymentStatus(None)
payments = [p for p in r.json()["payments"] if p["payment_hash"] == checking_id]
payment = payments[0] if payments else None
statuses = {"UNKNOWN": None, "IN_FLIGHT": None, "SUCCEEDED": True, "FAILED": False}
return PaymentStatus(statuses[payment["status"]] if payment else None)
raise KeyError("supplied an invalid checking_id")

View File

@ -12,11 +12,11 @@ class LNbitsWallet(Wallet):
self.auth_admin = {"X-Api-Key": getenv("LNBITS_ADMIN_KEY")}
self.auth_invoice = {"X-Api-Key": getenv("LNBITS_INVOICE_KEY")}
def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse:
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
r = post(
url=f"{self.endpoint}/api/v1/payments",
headers=self.auth_invoice,
json={"out": False, "amount": amount, "memo": memo}
json={"out": False, "amount": amount, "memo": memo, "description_hash": description_hash.hex(),},
)
ok, checking_id, payment_request, error_message = r.ok, None, None, None
@ -29,11 +29,7 @@ class LNbitsWallet(Wallet):
return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(
url=f"{self.endpoint}/api/v1/payments",
headers=self.auth_admin,
json={"out": True, "bolt11": bolt11}
)
r = post(url=f"{self.endpoint}/api/v1/payments", headers=self.auth_admin, json={"out": True, "bolt11": bolt11})
ok, checking_id, fee_msat, error_message = True, None, 0, None
if r.ok:
@ -50,7 +46,7 @@ class LNbitsWallet(Wallet):
if not r.ok:
return PaymentStatus(None)
return PaymentStatus(r.json()['paid'])
return PaymentStatus(r.json()["paid"])
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/api/v1/payments/{checking_id}", headers=self.auth_invoice)
@ -58,4 +54,4 @@ class LNbitsWallet(Wallet):
if not r.ok:
return PaymentStatus(None)
return PaymentStatus(r.json()['paid'])
return PaymentStatus(r.json()["paid"])

View File

@ -23,7 +23,7 @@ class LndWallet(Wallet):
self.auth_read = getenv("LND_READ_MACAROON")
self.auth_cert = getenv("LND_CERT")
def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse:
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
lnd_rpc = lnd_grpc.Client(
lnd_dir=None,
macaroon_path=self.auth_invoice,
@ -33,7 +33,13 @@ class LndWallet(Wallet):
grpc_port=self.port,
)
lndResponse = lnd_rpc.add_invoice(memo=memo, value=amount, expiry=600, private=True)
lndResponse = lnd_rpc.add_invoice(
memo=memo,
description_hash=base64.b64encode(description_hash).decode("ascii"),
value=amount,
expiry=600,
private=True,
)
decoded_hash = base64.b64encode(lndResponse.r_hash).decode("utf-8").replace("/", "_")
print(lndResponse.r_hash)
ok, checking_id, payment_request, error_message = True, decoded_hash, str(lndResponse.payment_request), None

View File

@ -1,5 +1,4 @@
from os import getenv
import os
import base64
from requests import get, post
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
@ -18,13 +17,17 @@ class LndRestWallet(Wallet):
self.auth_read = {"Grpc-Metadata-macaroon": getenv("LND_REST_READ_MACAROON")}
self.auth_cert = getenv("LND_REST_CERT")
def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse:
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
r = post(
url=f"{self.endpoint}/v1/invoices",
headers=self.auth_invoice, verify=self.auth_cert,
json={"value": amount, "memo": memo, "private": True},
headers=self.auth_invoice,
verify=self.auth_cert,
json={
"value": amount,
"memo": memo,
"description_hash": base64.b64encode(description_hash).decode("ascii"),
"private": True,
},
)
print(self.auth_invoice)
@ -37,17 +40,19 @@ class LndRestWallet(Wallet):
r = get(url=f"{self.endpoint}/v1/payreq/{payment_request}", headers=self.auth_read, verify=self.auth_cert,)
print(r)
if r.ok:
checking_id = r.json()["payment_hash"].replace("/","_")
checking_id = r.json()["payment_hash"].replace("/", "_")
print(checking_id)
error_message = None
ok = True
return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
r = post(
url=f"{self.endpoint}/v1/channels/transactions", headers=self.auth_admin, verify=self.auth_cert, json={"payment_request": bolt11}
url=f"{self.endpoint}/v1/channels/transactions",
headers=self.auth_admin,
verify=self.auth_cert,
json={"payment_request": bolt11},
)
ok, checking_id, fee_msat, error_message = r.ok, None, 0, None
r = get(url=f"{self.endpoint}/v1/payreq/{bolt11}", headers=self.auth_admin, verify=self.auth_cert,)
@ -59,9 +64,8 @@ class LndRestWallet(Wallet):
return PaymentResponse(ok, checking_id, fee_msat, error_message)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
checking_id = checking_id.replace("_","/")
checking_id = checking_id.replace("_", "/")
print(checking_id)
r = get(url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth_invoice, verify=self.auth_cert,)
print(r.json()["settled"])
@ -71,7 +75,12 @@ class LndRestWallet(Wallet):
return PaymentStatus(r.json()["settled"])
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = get(url=f"{self.endpoint}/v1/payments", headers=self.auth_admin, verify=self.auth_cert, params={"include_incomplete": "True", "max_payments": "20"})
r = get(
url=f"{self.endpoint}/v1/payments",
headers=self.auth_admin,
verify=self.auth_cert,
params={"include_incomplete": "True", "max_payments": "20"},
)
if not r.ok:
return PaymentStatus(None)

View File

@ -15,13 +15,13 @@ class LNPayWallet(Wallet):
self.auth_read = getenv("LNPAY_READ_KEY")
self.auth_api = {"X-Api-Key": getenv("LNPAY_API_KEY")}
def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse:
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
r = post(
url=f"{self.endpoint}/user/wallet/{self.auth_invoice}/invoice",
headers=self.auth_api,
json={"num_satoshis": f"{amount}", "memo": memo},
json={"num_satoshis": f"{amount}", "memo": memo, "description_hash": description_hash.hex(),},
)
ok, checking_id, payment_request, error_message = r.status_code == 201, None, None, None
ok, checking_id, payment_request, error_message = r.status_code == 201, None, None, r.text
if ok:
data = r.json()

View File

@ -13,8 +13,12 @@ class LntxbotWallet(Wallet):
self.auth_admin = {"Authorization": f"Basic {getenv('LNTXBOT_ADMIN_KEY')}"}
self.auth_invoice = {"Authorization": f"Basic {getenv('LNTXBOT_INVOICE_KEY')}"}
def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse:
r = post(url=f"{self.endpoint}/addinvoice", headers=self.auth_invoice, json={"amt": str(amount), "memo": memo})
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
r = post(
url=f"{self.endpoint}/addinvoice",
headers=self.auth_invoice,
json={"amt": str(amount), "memo": memo, "description_hash": description_hash.hex()},
)
ok, checking_id, payment_request, error_message = r.ok, None, None, None
if r.ok:

View File

@ -1,7 +1,7 @@
from os import getenv
from requests import get, post
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet, Unsupported
class OpenNodeWallet(Wallet):
@ -13,7 +13,10 @@ class OpenNodeWallet(Wallet):
self.auth_admin = {"Authorization": getenv("OPENNODE_ADMIN_KEY")}
self.auth_invoice = {"Authorization": getenv("OPENNODE_INVOICE_KEY")}
def create_invoice(self, amount: int, memo: str = "") -> InvoiceResponse:
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
if description_hash:
raise Unsupported("description_hash")
r = post(
url=f"{self.endpoint}/v1/charges",
headers=self.auth_invoice,

86
lnbits/wallets/spark.py Normal file
View File

@ -0,0 +1,86 @@
import random
import requests
from os import getenv
from .base import InvoiceResponse, PaymentResponse, PaymentStatus, Wallet
class SparkError(Exception):
pass
class UnknownError(Exception):
pass
class SparkWallet(Wallet):
def __init__(self):
self.url = getenv("SPARK_URL")
self.token = getenv("SPARK_TOKEN")
def __getattr__(self, key):
def call(*args, **kwargs):
if args and kwargs:
raise TypeError(f"must supply either named arguments or a list of arguments, not both: {args} {kwargs}")
elif args:
params = args
elif kwargs:
params = kwargs
r = requests.post(self.url, headers={"X-Access": self.token}, json={"method": key, "params": params})
try:
data = r.json()
except:
raise UnknownError(r.text)
if not r.ok:
raise SparkError(data["message"])
return data
return call
def create_invoice(self, amount: int, memo: str = "", description_hash: bytes = b"") -> InvoiceResponse:
label = "lbs{}".format(random.random())
checking_id = label
try:
if description_hash:
r = self.invoicewithdescriptionhash(
msatoshi=amount * 1000, label=label, description_hash=description_hash.hex(),
)
else:
r = self.invoice(msatoshi=amount * 1000, label=label, description=memo, exposeprivatechannels=True)
ok, payment_request, error_message = True, r["bolt11"], ""
except (SparkError, UnknownError) as e:
ok, payment_request, error_message = False, None, str(e)
return InvoiceResponse(ok, checking_id, payment_request, error_message)
def pay_invoice(self, bolt11: str) -> PaymentResponse:
try:
r = self.pay(bolt11)
ok, checking_id, fee_msat, error_message = True, r["payment_hash"], r["msatoshi_sent"] - r["msatoshi"], None
except (SparkError, UnknownError) as e:
ok, checking_id, fee_msat, error_message = False, None, None, str(e)
return PaymentResponse(ok, checking_id, fee_msat, error_message)
def get_invoice_status(self, checking_id: str) -> PaymentStatus:
r = self.listinvoices(label=checking_id)
if not r or not r.get("invoices"):
return PaymentStatus(None)
if r["invoices"][0]["status"] == "unpaid":
return PaymentStatus(False)
return PaymentStatus(True)
def get_payment_status(self, checking_id: str) -> PaymentStatus:
r = self.listpays(payment_hash=checking_id)
if not r["pays"]:
return PaymentStatus(False)
if r["pays"][0]["payment_hash"] == checking_id:
status = r["pays"][0]["status"]
if status == "complete":
return PaymentStatus(True)
elif status == "failed":
return PaymentStatus(False)
return PaymentStatus(None)
raise KeyError("supplied an invalid checking_id")