commit ad9fd4e4add927e799af25700c8db327e6108cc1
Author: Arc <33088785+arcbtc@users.noreply.github.com>
Date: Thu Feb 16 10:03:33 2023 +0000
Add files via upload
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0832bfb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,27 @@
+# LNURLp
+
+## Create a static QR code people can use to pay over Lightning Network
+
+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 invoice is issued by the service and sent to the wallet.
+
+[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
+
+## Usage
+
+1. Create an LNURLp (New Pay link)\
+ 
+
+ - select your wallets
+ - make a small description
+ - enter amount
+ - if _Fixed amount_ is unchecked you'll have the option to configure a Max and Min amount
+ - you can set the currency to something different than sats. For example if you choose EUR, the satoshi amount will be calculated when a user scans the LNURLp
+ - You can ask the user to send a comment that will be sent along with the payment (for example a comment to a blog post)
+ - Webhook URL allows to call an URL when the LNURLp is paid
+ - Success mesage, will send a message back to the user after a successful payment, for example a thank you note
+ - Success URL, will send back a clickable link to the user. Access to some hidden content, or a download link
+
+2. Use the shareable link or view the LNURLp you just created\
+ 
+ - you can now open your LNURLp and copy the LNURL, get the shareable link or print it\
+ 
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..f5ea0cd
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,35 @@
+import asyncio
+
+from fastapi import APIRouter
+from fastapi.staticfiles import StaticFiles
+
+from lnbits.db import Database
+from lnbits.helpers import template_renderer
+from lnbits.tasks import catch_everything_and_restart
+
+db = Database("ext_lnurlp")
+
+lnurlp_static_files = [
+ {
+ "path": "/lnurlp/static",
+ "app": StaticFiles(packages=[("lnbits", "extensions/lnurlp/static")]),
+ "name": "lnurlp_static",
+ }
+]
+
+lnurlp_ext: APIRouter = APIRouter(prefix="/lnurlp", tags=["lnurlp"])
+
+
+def lnurlp_renderer():
+ return template_renderer(["lnbits/extensions/lnurlp/templates"])
+
+
+from .lnurl import * # noqa: F401,F403
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa: F401,F403
+from .views_api import * # noqa: F401,F403
+
+
+def lnurlp_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/config.json b/config.json
new file mode 100644
index 0000000..d3e046d
--- /dev/null
+++ b/config.json
@@ -0,0 +1,10 @@
+{
+ "name": "LNURLp",
+ "short_description": "Make reusable LNURL pay links",
+ "tile": "/lnurlp/static/image/lnurl-pay.png",
+ "contributors": [
+ "arcbtc",
+ "eillarra",
+ "fiatjaf"
+ ]
+}
diff --git a/crud.py b/crud.py
new file mode 100644
index 0000000..4acb4a4
--- /dev/null
+++ b/crud.py
@@ -0,0 +1,95 @@
+from typing import List, Optional, Union
+
+from lnbits.helpers import urlsafe_short_hash
+
+from . import db
+from .models import CreatePayLinkData, PayLink
+
+
+async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
+ link_id = urlsafe_short_hash()[:6]
+
+ result = await db.execute(
+ """
+ INSERT INTO lnurlp.pay_links (
+ id,
+ wallet,
+ description,
+ min,
+ max,
+ served_meta,
+ served_pr,
+ webhook_url,
+ webhook_headers,
+ webhook_body,
+ success_text,
+ success_url,
+ comment_chars,
+ currency,
+ fiat_base_multiplier
+ )
+ VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ link_id,
+ wallet_id,
+ data.description,
+ data.min,
+ data.max,
+ data.webhook_url,
+ data.webhook_headers,
+ data.webhook_body,
+ data.success_text,
+ data.success_url,
+ data.comment_chars,
+ data.currency,
+ data.fiat_base_multiplier,
+ ),
+ )
+ assert result
+
+ link = await get_pay_link(link_id)
+ assert link, "Newly created link couldn't be retrieved"
+ return link
+
+
+async def get_pay_link(link_id: str) -> Optional[PayLink]:
+ row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
+ return PayLink.from_row(row) if row else None
+
+
+async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[PayLink]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"""
+ SELECT * FROM lnurlp.pay_links WHERE wallet IN ({q})
+ ORDER BY Id
+ """,
+ (*wallet_ids,),
+ )
+ return [PayLink.from_row(row) for row in rows]
+
+
+async def update_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
+ )
+ row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
+ return PayLink.from_row(row) if row else None
+
+
+async def increment_pay_link(link_id: int, **kwargs) -> Optional[PayLink]:
+ q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE lnurlp.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
+ )
+ row = await db.fetchone("SELECT * FROM lnurlp.pay_links WHERE id = ?", (link_id,))
+ return PayLink.from_row(row) if row else None
+
+
+async def delete_pay_link(link_id: int) -> None:
+ await db.execute("DELETE FROM lnurlp.pay_links WHERE id = ?", (link_id,))
diff --git a/lnurl.py b/lnurl.py
new file mode 100644
index 0000000..918a5bd
--- /dev/null
+++ b/lnurl.py
@@ -0,0 +1,106 @@
+from http import HTTPStatus
+
+from fastapi import Request
+from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
+from starlette.exceptions import HTTPException
+
+from lnbits.core.services import create_invoice
+from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
+
+from . import lnurlp_ext
+from .crud import increment_pay_link
+
+
+@lnurlp_ext.get(
+ "/api/v1/lnurl/{link_id}", # Backwards compatibility for old LNURLs / QR codes (with long URL)
+ status_code=HTTPStatus.OK,
+ name="lnurlp.api_lnurl_response.deprecated",
+)
+@lnurlp_ext.get(
+ "/{link_id}",
+ status_code=HTTPStatus.OK,
+ name="lnurlp.api_lnurl_response",
+)
+async def api_lnurl_response(request: Request, link_id):
+ link = await increment_pay_link(link_id, served_meta=1)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
+ )
+
+ rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
+
+ resp = LnurlPayResponse(
+ callback=request.url_for("lnurlp.api_lnurl_callback", link_id=link.id),
+ min_sendable=round(link.min * rate) * 1000,
+ max_sendable=round(link.max * rate) * 1000,
+ metadata=link.lnurlpay_metadata,
+ )
+ params = resp.dict()
+
+ if link.comment_chars > 0:
+ params["commentAllowed"] = link.comment_chars
+
+ return params
+
+
+@lnurlp_ext.get(
+ "/api/v1/lnurl/cb/{link_id}",
+ status_code=HTTPStatus.OK,
+ name="lnurlp.api_lnurl_callback",
+)
+async def api_lnurl_callback(request: Request, link_id):
+ link = await increment_pay_link(link_id, served_pr=1)
+ if not link:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Pay link does not exist."
+ )
+ min, max = link.min, link.max
+ rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
+ if link.currency:
+ # allow some fluctuation (as the fiat price may have changed between the calls)
+ min = rate * 995 * link.min
+ max = rate * 1010 * link.max
+ else:
+ min = link.min * 1000
+ max = link.max * 1000
+
+ amount_received = int(request.query_params.get("amount") or 0)
+ if amount_received < min:
+ return LnurlErrorResponse(
+ reason=f"Amount {amount_received} is smaller than minimum {min}."
+ ).dict()
+
+ elif amount_received > max:
+ return LnurlErrorResponse(
+ reason=f"Amount {amount_received} is greater than maximum {max}."
+ ).dict()
+
+ comment = request.query_params.get("comment")
+ if len(comment or "") > link.comment_chars:
+ return LnurlErrorResponse(
+ reason=f"Got a comment with {len(comment)} characters, but can only accept {link.comment_chars}"
+ ).dict()
+
+ payment_hash, payment_request = await create_invoice(
+ wallet_id=link.wallet,
+ amount=int(amount_received / 1000),
+ memo=link.description,
+ unhashed_description=link.lnurlpay_metadata.encode(),
+ extra={
+ "tag": "lnurlp",
+ "link": link.id,
+ "comment": comment,
+ "extra": request.query_params.get("amount"),
+ },
+ )
+
+ success_action = link.success_action(payment_hash)
+ if success_action:
+ resp = LnurlPayActionResponse(
+ pr=payment_request, success_action=success_action, routes=[]
+ )
+ else:
+ resp = LnurlPayActionResponse(pr=payment_request, routes=[])
+
+ return resp.dict()
diff --git a/migrations.py b/migrations.py
new file mode 100644
index 0000000..1ec85eb
--- /dev/null
+++ b/migrations.py
@@ -0,0 +1,148 @@
+async def m001_initial(db):
+ """
+ Initial pay table.
+ """
+ await db.execute(
+ f"""
+ CREATE TABLE lnurlp.pay_links (
+ id {db.serial_primary_key},
+ wallet TEXT NOT NULL,
+ description TEXT NOT NULL,
+ amount {db.big_int} NOT NULL,
+ served_meta INTEGER NOT NULL,
+ served_pr INTEGER NOT NULL
+ );
+ """
+ )
+
+
+async def m002_webhooks_and_success_actions(db):
+ """
+ Webhooks and success actions.
+ """
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_url TEXT;")
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_text TEXT;")
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN success_url TEXT;")
+ await db.execute(
+ f"""
+ CREATE TABLE lnurlp.invoices (
+ pay_link INTEGER NOT NULL REFERENCES {db.references_schema}pay_links (id),
+ payment_hash TEXT NOT NULL,
+ webhook_sent INT, -- null means not sent, otherwise store status
+ expiry INT
+ );
+ """
+ )
+
+
+async def m003_min_max_comment_fiat(db):
+ """
+ Support for min/max amounts, comments and fiat prices that get
+ converted automatically to satoshis based on some API.
+ """
+ await db.execute(
+ "ALTER TABLE lnurlp.pay_links ADD COLUMN currency TEXT;"
+ ) # null = satoshis
+ await db.execute(
+ "ALTER TABLE lnurlp.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
+ )
+ await db.execute("ALTER TABLE lnurlp.pay_links RENAME COLUMN amount TO min;")
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN max INTEGER;")
+ await db.execute("UPDATE lnurlp.pay_links SET max = min;")
+ await db.execute("DROP TABLE lnurlp.invoices")
+
+
+async def m004_fiat_base_multiplier(db):
+ """
+ Store the multiplier for fiat prices. We store the price in cents and
+ remember to multiply by 100 when we use it to convert to Dollars.
+ """
+ await db.execute(
+ "ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
+ )
+
+
+async def m005_webhook_headers_and_body(db):
+ """
+ Add headers and body to webhooks
+ """
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_headers TEXT;")
+ await db.execute("ALTER TABLE lnurlp.pay_links ADD COLUMN webhook_body TEXT;")
+
+
+async def m006_redux(db):
+ """
+ Migrate ID column type to string for UUIDs and migrate existing data
+ """
+ # we can simply change the column type for postgres
+ if db.type != "SQLITE":
+ await db.execute("ALTER TABLE lnurlp.pay_links ALTER COLUMN id TYPE TEXT;")
+ else:
+ # but we have to do this for sqlite
+ await db.execute("ALTER TABLE lnurlp.pay_links RENAME TO pay_links_old")
+ await db.execute(
+ f"""
+ CREATE TABLE lnurlp.pay_links (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ description TEXT NOT NULL,
+ min {db.big_int} NOT NULL,
+ max {db.big_int},
+ currency TEXT,
+ fiat_base_multiplier INTEGER DEFAULT 1,
+ served_meta INTEGER NOT NULL,
+ served_pr INTEGER NOT NULL,
+ webhook_url TEXT,
+ success_text TEXT,
+ success_url TEXT,
+ comment_chars INTEGER DEFAULT 0,
+ webhook_headers TEXT,
+ webhook_body TEXT
+ );
+ """
+ )
+
+ for row in [
+ list(row) for row in await db.fetchall("SELECT * FROM lnurlp.pay_links_old")
+ ]:
+ await db.execute(
+ """
+ INSERT INTO lnurlp.pay_links (
+ id,
+ wallet,
+ description,
+ min,
+ served_meta,
+ served_pr,
+ webhook_url,
+ success_text,
+ success_url,
+ currency,
+ comment_chars,
+ max,
+ fiat_base_multiplier,
+ webhook_headers,
+ webhook_body
+ )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ row[0],
+ row[1],
+ row[2],
+ row[3],
+ row[4],
+ row[5],
+ row[6],
+ row[7],
+ row[8],
+ row[9],
+ row[10],
+ row[11],
+ row[12],
+ row[13],
+ row[14],
+ ),
+ )
+
+ await db.execute("DROP TABLE lnurlp.pay_links_old")
diff --git a/models.py b/models.py
new file mode 100644
index 0000000..4ee82aa
--- /dev/null
+++ b/models.py
@@ -0,0 +1,75 @@
+import json
+from sqlite3 import Row
+from typing import Dict, Optional
+from urllib.parse import ParseResult, urlparse, urlunparse
+
+from fastapi.param_functions import Query
+from lnurl.types import LnurlPayMetadata
+from pydantic import BaseModel
+from starlette.requests import Request
+
+from lnbits.lnurl import encode as lnurl_encode
+
+
+class CreatePayLinkData(BaseModel):
+ description: str
+ min: float = Query(1, ge=0.01)
+ max: float = Query(1, ge=0.01)
+ currency: str = Query(None)
+ comment_chars: int = Query(0, ge=0, lt=800)
+ webhook_url: str = Query(None)
+ webhook_headers: str = Query(None)
+ webhook_body: str = Query(None)
+ success_text: str = Query(None)
+ success_url: str = Query(None)
+ fiat_base_multiplier: int = Query(100, ge=1)
+
+
+class PayLink(BaseModel):
+ id: str
+ wallet: str
+ description: str
+ min: float
+ served_meta: int
+ served_pr: int
+ webhook_url: Optional[str]
+ webhook_headers: Optional[str]
+ webhook_body: Optional[str]
+ success_text: Optional[str]
+ success_url: Optional[str]
+ currency: Optional[str]
+ comment_chars: int
+ max: float
+ fiat_base_multiplier: int
+
+ @classmethod
+ def from_row(cls, row: Row) -> "PayLink":
+ data = dict(row)
+ if data["currency"] and data["fiat_base_multiplier"]:
+ data["min"] /= data["fiat_base_multiplier"]
+ data["max"] /= data["fiat_base_multiplier"]
+ return cls(**data)
+
+ def lnurl(self, req: Request) -> str:
+ url = req.url_for("lnurlp.api_lnurl_response", link_id=self.id)
+ return lnurl_encode(url)
+
+ @property
+ def lnurlpay_metadata(self) -> LnurlPayMetadata:
+ return LnurlPayMetadata(json.dumps([["text/plain", self.description]]))
+
+ def success_action(self, payment_hash: str) -> Optional[Dict]:
+ if self.success_url:
+ url: ParseResult = urlparse(self.success_url)
+ #qs = parse_qs(url.query)
+ #setattr(qs, "payment_hash", payment_hash)
+ #url = url._replace(query=urlencode(qs, doseq=True))
+ return {
+ "tag": "url",
+ "description": self.success_text or "~",
+ "url": urlunparse(url),
+ }
+ elif self.success_text:
+ return {"tag": "message", "message": self.success_text}
+ else:
+ return None
diff --git a/static/image/lnurl-pay.png b/static/image/lnurl-pay.png
new file mode 100644
index 0000000..36af81a
Binary files /dev/null and b/static/image/lnurl-pay.png differ
diff --git a/static/js/index.js b/static/js/index.js
new file mode 100644
index 0000000..c1372be
--- /dev/null
+++ b/static/js/index.js
@@ -0,0 +1,264 @@
+/* globals Quasar, Vue, _, VueQrcode, windowMixin, LNbits, LOCALE */
+
+Vue.component(VueQrcode.name, VueQrcode)
+
+var locationPath = [
+ window.location.protocol,
+ '//',
+ window.location.host,
+ window.location.pathname
+].join('')
+
+var mapPayLink = 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, 'link/', obj.id].join('')
+ return obj
+}
+
+new Vue({
+ el: '#vue',
+ mixins: [windowMixin],
+ data() {
+ return {
+ currencies: [],
+ fiatRates: {},
+ checker: null,
+ payLinks: [],
+ payLinksTable: {
+ pagination: {
+ rowsPerPage: 10
+ }
+ },
+ nfcTagWriting: false,
+ formDialog: {
+ show: false,
+ fixedAmount: true,
+ data: {}
+ },
+ qrCodeDialog: {
+ show: false,
+ data: null
+ }
+ }
+ },
+ methods: {
+ getPayLinks() {
+ LNbits.api
+ .request(
+ 'GET',
+ '/lnurlp/api/v1/links?all_wallets=true',
+ this.g.user.wallets[0].inkey
+ )
+ .then(response => {
+ this.payLinks = response.data.map(mapPayLink)
+ })
+ .catch(err => {
+ clearInterval(this.checker)
+ LNbits.utils.notifyApiError(err)
+ })
+ },
+ closeFormDialog() {
+ this.resetFormData()
+ },
+ openQrCodeDialog(linkId) {
+ var link = _.findWhere(this.payLinks, {id: linkId})
+ if (link.currency) this.updateFiatRate(link.currency)
+
+ this.qrCodeDialog.data = {
+ id: link.id,
+ amount:
+ (link.min === link.max ? link.min : `${link.min} - ${link.max}`) +
+ ' ' +
+ (link.currency || 'sat'),
+ currency: link.currency,
+ comments: link.comment_chars
+ ? `${link.comment_chars} characters`
+ : 'no',
+ webhook: link.webhook_url || 'nowhere',
+ success:
+ link.success_text || link.success_url
+ ? 'Display message "' +
+ link.success_text +
+ '"' +
+ (link.success_url ? ' and URL "' + link.success_url + '"' : '')
+ : 'do nothing',
+ lnurl: link.lnurl,
+ pay_url: link.pay_url,
+ print_url: link.print_url
+ }
+ this.qrCodeDialog.show = true
+ },
+ openUpdateDialog(linkId) {
+ const link = _.findWhere(this.payLinks, {id: linkId})
+ if (link.currency) this.updateFiatRate(link.currency)
+
+ this.formDialog.data = _.clone(link._data)
+ this.formDialog.show = true
+ this.formDialog.fixedAmount =
+ this.formDialog.data.min === this.formDialog.data.max
+ },
+ sendFormData() {
+ const wallet = _.findWhere(this.g.user.wallets, {
+ id: this.formDialog.data.wallet
+ })
+ var data = _.omit(this.formDialog.data, 'wallet')
+
+ if (this.formDialog.fixedAmount) data.max = data.min
+ if (data.currency === 'satoshis') data.currency = null
+ if (isNaN(parseInt(data.comment_chars))) data.comment_chars = 0
+
+ if (data.id) {
+ this.updatePayLink(wallet, data)
+ } else {
+ this.createPayLink(wallet, data)
+ }
+ },
+ resetFormData() {
+ this.formDialog = {
+ show: false,
+ fixedAmount: true,
+ data: {}
+ }
+ },
+ updatePayLink(wallet, data) {
+ let values = _.omit(
+ _.pick(
+ data,
+ 'description',
+ 'min',
+ 'max',
+ 'webhook_url',
+ 'success_text',
+ 'success_url',
+ 'comment_chars',
+ 'currency'
+ ),
+ (value, key) =>
+ (key === 'webhook_url' ||
+ key === 'success_text' ||
+ key === 'success_url') &&
+ (value === null || value === '')
+ )
+
+ LNbits.api
+ .request(
+ 'PUT',
+ '/lnurlp/api/v1/links/' + data.id,
+ wallet.adminkey,
+ values
+ )
+ .then(response => {
+ this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
+ this.payLinks.push(mapPayLink(response.data))
+ this.formDialog.show = false
+ this.resetFormData()
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ },
+ createPayLink(wallet, data) {
+ LNbits.api
+ .request('POST', '/lnurlp/api/v1/links', wallet.adminkey, data)
+ .then(response => {
+ this.getPayLinks()
+ this.formDialog.show = false
+ this.resetFormData()
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ },
+ deletePayLink(linkId) {
+ var link = _.findWhere(this.payLinks, {id: linkId})
+
+ LNbits.utils
+ .confirmDialog('Are you sure you want to delete this pay link?')
+ .onOk(() => {
+ LNbits.api
+ .request(
+ 'DELETE',
+ '/lnurlp/api/v1/links/' + linkId,
+ _.findWhere(this.g.user.wallets, {id: link.wallet}).adminkey
+ )
+ .then(response => {
+ this.payLinks = _.reject(this.payLinks, obj => obj.id === linkId)
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ })
+ },
+ updateFiatRate(currency) {
+ LNbits.api
+ .request('GET', '/lnurlp/api/v1/rate/' + currency, null)
+ .then(response => {
+ let rates = _.clone(this.fiatRates)
+ rates[currency] = response.data.rate
+ this.fiatRates = rates
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ },
+ writeNfcTag: async function (lnurl) {
+ try {
+ if (typeof NDEFReader == 'undefined') {
+ throw {
+ toString: function () {
+ return 'NFC not supported on this device or browser.'
+ }
+ }
+ }
+
+ const ndef = new NDEFReader()
+
+ this.nfcTagWriting = true
+ this.$q.notify({
+ message: 'Tap your NFC tag to write the LNURL-pay link to it.'
+ })
+
+ await ndef.write({
+ records: [{recordType: 'url', data: 'lightning:' + lnurl, lang: 'en'}]
+ })
+
+ this.nfcTagWriting = false
+ this.$q.notify({
+ type: 'positive',
+ message: 'NFC tag written successfully.'
+ })
+ } catch (error) {
+ this.nfcTagWriting = false
+ this.$q.notify({
+ type: 'negative',
+ message: error
+ ? error.toString()
+ : 'An unexpected error has occurred.'
+ })
+ }
+ }
+ },
+ created() {
+ if (this.g.user.wallets.length) {
+ var getPayLinks = this.getPayLinks
+ getPayLinks()
+ this.checker = setInterval(() => {
+ getPayLinks()
+ }, 20000)
+ }
+ LNbits.api
+ .request('GET', '/lnurlp/api/v1/currencies')
+ .then(response => {
+ this.currencies = ['satoshis', ...response.data]
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ }
+})
diff --git a/tasks.py b/tasks.py
new file mode 100644
index 0000000..ea01e04
--- /dev/null
+++ b/tasks.py
@@ -0,0 +1,79 @@
+import asyncio
+import json
+
+import httpx
+from loguru import logger
+
+from lnbits.core.crud import update_payment_extra
+from lnbits.core.models import Payment
+from lnbits.helpers import get_current_extension_name
+from lnbits.tasks import register_invoice_listener
+
+from .crud import get_pay_link
+
+
+async def wait_for_paid_invoices():
+ invoice_queue = asyncio.Queue()
+ register_invoice_listener(invoice_queue, get_current_extension_name())
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Payment):
+ if payment.extra.get("tag") != "lnurlp":
+ return
+
+ if payment.extra.get("wh_status"):
+ # this webhook has already been sent
+ return
+
+ pay_link = await get_pay_link(payment.extra.get("link", -1))
+ if pay_link and pay_link.webhook_url:
+ async with httpx.AsyncClient() as client:
+ try:
+ r: httpx.Response = await client.post(
+ pay_link.webhook_url,
+ json={
+ "payment_hash": payment.payment_hash,
+ "payment_request": payment.bolt11,
+ "amount": payment.amount,
+ "comment": payment.extra.get("comment"),
+ "lnurlp": pay_link.id,
+ "body": json.loads(pay_link.webhook_body)
+ if pay_link.webhook_body
+ else "",
+ },
+ headers=json.loads(pay_link.webhook_headers)
+ if pay_link.webhook_headers
+ else None,
+ timeout=40,
+ )
+ await mark_webhook_sent(
+ payment.payment_hash,
+ r.status_code,
+ r.is_success,
+ r.reason_phrase,
+ r.text,
+ )
+ except Exception as ex:
+ logger.error(ex)
+ await mark_webhook_sent(
+ payment.payment_hash, -1, False, "Unexpected Error", str(ex)
+ )
+
+
+async def mark_webhook_sent(
+ payment_hash: str, status: int, is_success: bool, reason_phrase="", text=""
+) -> None:
+
+ await update_payment_extra(
+ payment_hash,
+ {
+ "wh_status": status, # keep for backwards compability
+ "wh_success": is_success,
+ "wh_message": reason_phrase,
+ "wh_response": text,
+ },
+ )
diff --git a/templates/lnurlp/_api_docs.html b/templates/lnurlp/_api_docs.html
new file mode 100644
index 0000000..abb37e9
--- /dev/null
+++ b/templates/lnurlp/_api_docs.html
@@ -0,0 +1,138 @@
+
+ WARNING: LNURL must be used over https or TOR
+ 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.
+ GET /lnurlp/api/v1/links
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ [<pay_link_object>, ...]
+ Curl example
+ curl -X GET {{ request.base_url }}lnurlp/api/v1/links -H "X-Api-Key:
+ {{ user.wallets[0].inkey }}"
+
+ GET
+ /lnurlp/api/v1/links/<pay_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 201 CREATED (application/json)
+
+ {"lnurl": <string>}
+ Curl example
+ curl -X GET {{ request.base_url }}lnurlp/api/v1/links/<pay_id>
+ -H "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+ POST /lnurlp/api/v1/links
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Body (application/json)
+ {"description": <string> "amount": <integer> "max":
+ <integer> "min": <integer> "comment_chars":
+ <integer>}
+
+ Returns 201 CREATED (application/json)
+
+ {"lnurl": <string>}
+ Curl example
+ curl -X POST {{ request.base_url }}lnurlp/api/v1/links -d
+ '{"description": <string>, "amount": <integer>, "max":
+ <integer>, "min": <integer>, "comment_chars":
+ <integer>}' -H "Content-type: application/json" -H "X-Api-Key:
+ {{ user.wallets[0].adminkey }}"
+
+ PUT
+ /lnurlp/api/v1/links/<pay_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Body (application/json)
+ {"description": <string>, "amount": <integer>}
+
+ Returns 200 OK (application/json)
+
+ {"lnurl": <string>}
+ Curl example
+ curl -X PUT {{ request.base_url }}lnurlp/api/v1/links/<pay_id>
+ -d '{"description": <string>, "amount": <integer>}' -H
+ "Content-type: application/json" -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
+
+ DELETE
+ /lnurlp/api/v1/links/<pay_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 204 NO CONTENT
+
+ Curl example
+ curl -X DELETE {{ request.base_url
+ }}lnurlp/api/v1/links/<pay_id> -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
+
+
+ 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.
+
Use an LNURL compatible bitcoin wallet to pay.
+
+ ID: {{ qrCodeDialog.data.id }}
+ Amount: {{ qrCodeDialog.data.amount }}
+ {{ qrCodeDialog.data.currency }} price: {{
+ fiatRates[qrCodeDialog.data.currency] ?
+ fiatRates[qrCodeDialog.data.currency] + ' sat' : 'Loading...' }}
+ Accepts comments: {{ qrCodeDialog.data.comments }}
+ Dispatches webhook to: {{ qrCodeDialog.data.webhook
+ }}
+ On success: {{ qrCodeDialog.data.success }}
+