diff --git a/lnbits/extensions/scrub/README.md b/lnbits/extensions/scrub/README.md
new file mode 100644
index 00000000..4a5c1c16
--- /dev/null
+++ b/lnbits/extensions/scrub/README.md
@@ -0,0 +1,27 @@
+# scrub
+
+## 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 scrub (New Scrub 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 scrub
+ - 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 scrub 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 scrub you just created\
+ 
+ - you can now open your scrub and copy the LNURL, get the shareable link or print it\
+ 
diff --git a/lnbits/extensions/scrub/__init__.py b/lnbits/extensions/scrub/__init__.py
new file mode 100644
index 00000000..3d25a097
--- /dev/null
+++ b/lnbits/extensions/scrub/__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_scrub")
+
+scrub_static_files = [
+ {
+ "path": "/scrub/static",
+ "app": StaticFiles(directory="lnbits/extensions/scrub/static"),
+ "name": "scrub_static",
+ }
+]
+
+scrub_ext: APIRouter = APIRouter(prefix="/scrub", tags=["scrub"])
+
+
+def scrub_renderer():
+ return template_renderer(["lnbits/extensions/scrub/templates"])
+
+
+from .lnurl import * # noqa
+from .tasks import wait_for_paid_invoices
+from .views import * # noqa
+from .views_api import * # noqa
+
+
+def scrub_start():
+ loop = asyncio.get_event_loop()
+ loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/scrub/config.json b/lnbits/extensions/scrub/config.json
new file mode 100644
index 00000000..9045b658
--- /dev/null
+++ b/lnbits/extensions/scrub/config.json
@@ -0,0 +1,8 @@
+{
+ "name": "scrub",
+ "short_description": "Pass payments to LNURLp/LNaddress",
+ "icon": "send",
+ "contributors": [
+ "arcbtc"
+ ]
+}
diff --git a/lnbits/extensions/scrub/crud.py b/lnbits/extensions/scrub/crud.py
new file mode 100644
index 00000000..df97f990
--- /dev/null
+++ b/lnbits/extensions/scrub/crud.py
@@ -0,0 +1,91 @@
+from typing import List, Optional, Union
+
+from lnbits.db import SQLITE
+from . import db
+from .models import ScrubLink, CreateScrubLinkData
+
+
+async def create_pay_link(data: CreateScrubLinkData, wallet_id: str) -> ScrubLink:
+
+ returning = "" if db.type == SQLITE else "RETURNING ID"
+ method = db.execute if db.type == SQLITE else db.fetchone
+ result = await (method)(
+ f"""
+ INSERT INTO scrub.pay_links (
+ wallet,
+ description,
+ min,
+ max,
+ served_meta,
+ served_pr,
+ webhook_url,
+ success_text,
+ success_url,
+ comment_chars,
+ currency
+ )
+ VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?)
+ {returning}
+ """,
+ (
+ wallet_id,
+ data.description,
+ data.min,
+ data.max,
+ data.webhook_url,
+ data.success_text,
+ data.success_url,
+ data.comment_chars,
+ data.currency,
+ ),
+ )
+ if db.type == SQLITE:
+ link_id = result._result_proxy.lastrowid
+ else:
+ link_id = result[0]
+
+ 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: int) -> Optional[ScrubLink]:
+ row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,))
+ return ScrubLink.from_row(row) if row else None
+
+
+async def get_pay_links(wallet_ids: Union[str, List[str]]) -> List[ScrubLink]:
+ if isinstance(wallet_ids, str):
+ wallet_ids = [wallet_ids]
+
+ q = ",".join(["?"] * len(wallet_ids))
+ rows = await db.fetchall(
+ f"""
+ SELECT * FROM scrub.pay_links WHERE wallet IN ({q})
+ ORDER BY Id
+ """,
+ (*wallet_ids,),
+ )
+ return [ScrubLink.from_row(row) for row in rows]
+
+
+async def update_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE scrub.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
+ )
+ row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,))
+ return ScrubLink.from_row(row) if row else None
+
+
+async def increment_pay_link(link_id: int, **kwargs) -> Optional[ScrubLink]:
+ q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE scrub.pay_links SET {q} WHERE id = ?", (*kwargs.values(), link_id)
+ )
+ row = await db.fetchone("SELECT * FROM scrub.pay_links WHERE id = ?", (link_id,))
+ return ScrubLink.from_row(row) if row else None
+
+
+async def delete_pay_link(link_id: int) -> None:
+ await db.execute("DELETE FROM scrub.pay_links WHERE id = ?", (link_id,))
diff --git a/lnbits/extensions/scrub/lnurl.py b/lnbits/extensions/scrub/lnurl.py
new file mode 100644
index 00000000..6d33479f
--- /dev/null
+++ b/lnbits/extensions/scrub/lnurl.py
@@ -0,0 +1,109 @@
+import hashlib
+import math
+from http import HTTPStatus
+
+from fastapi import Request
+from lnurl import ( # type: ignore
+ LnurlErrorResponse,
+ LnurlScrubActionResponse,
+ LnurlScrubResponse,
+)
+from starlette.exceptions import HTTPException
+
+from lnbits.core.services import create_invoice
+from lnbits.utils.exchange_rates import get_fiat_rate_satoshis
+
+from . import scrub_ext
+from .crud import increment_pay_link
+
+
+@scrub_ext.get(
+ "/api/v1/lnurl/{link_id}",
+ status_code=HTTPStatus.OK,
+ name="scrub.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="Scrub link does not exist."
+ )
+
+ rate = await get_fiat_rate_satoshis(link.currency) if link.currency else 1
+
+ resp = LnurlScrubResponse(
+ callback=request.url_for("scrub.api_lnurl_callback", link_id=link.id),
+ min_sendable=math.ceil(link.min * rate) * 1000,
+ max_sendable=round(link.max * rate) * 1000,
+ metadata=link.scrubay_metadata,
+ )
+ params = resp.dict()
+
+ if link.comment_chars > 0:
+ params["commentAllowed"] = link.comment_chars
+
+ return params
+
+
+@scrub_ext.get(
+ "/api/v1/lnurl/cb/{link_id}",
+ status_code=HTTPStatus.OK,
+ name="scrub.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="Scrub 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,
+ description_hash=hashlib.sha256(
+ link.scrubay_metadata.encode("utf-8")
+ ).digest(),
+ extra={
+ "tag": "scrub",
+ "link": link.id,
+ "comment": comment,
+ "extra": request.query_params.get("amount"),
+ },
+ )
+
+ success_action = link.success_action(payment_hash)
+ if success_action:
+ resp = LnurlScrubActionResponse(
+ pr=payment_request, success_action=success_action, routes=[]
+ )
+ else:
+ resp = LnurlScrubActionResponse(pr=payment_request, routes=[])
+
+ return resp.dict()
diff --git a/lnbits/extensions/scrub/migrations.py b/lnbits/extensions/scrub/migrations.py
new file mode 100644
index 00000000..f16a61b1
--- /dev/null
+++ b/lnbits/extensions/scrub/migrations.py
@@ -0,0 +1,51 @@
+async def m001_initial(db):
+ """
+ Initial pay table.
+ """
+ await db.execute(
+ f"""
+ CREATE TABLE scrub.pay_links (
+ id {db.serial_primary_key},
+ wallet TEXT NOT NULL,
+ description TEXT NOT NULL,
+ webhook INTEGER NOT NULL,
+ payoraddress INTEGER NOT NULL
+ );
+ """
+ )
+
+
+async def m002_webhooks_and_success_actions(db):
+ """
+ Webhooks and success actions.
+ """
+ await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN webhook_url TEXT;")
+ await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN success_text TEXT;")
+ await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN success_url TEXT;")
+ await db.execute(
+ f"""
+ CREATE TABLE scrub.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 scrub.pay_links ADD COLUMN currency TEXT;"
+ ) # null = satoshis
+ await db.execute(
+ "ALTER TABLE scrub.pay_links ADD COLUMN comment_chars INTEGER DEFAULT 0;"
+ )
+ await db.execute("ALTER TABLE scrub.pay_links RENAME COLUMN amount TO min;")
+ await db.execute("ALTER TABLE scrub.pay_links ADD COLUMN max INTEGER;")
+ await db.execute("UPDATE scrub.pay_links SET max = min;")
+ await db.execute("DROP TABLE scrub.invoices")
diff --git a/lnbits/extensions/scrub/models.py b/lnbits/extensions/scrub/models.py
new file mode 100644
index 00000000..6a4ee9d7
--- /dev/null
+++ b/lnbits/extensions/scrub/models.py
@@ -0,0 +1,64 @@
+import json
+from urllib.parse import urlparse, urlunparse, parse_qs, urlencode, ParseResult
+from starlette.requests import Request
+from fastapi.param_functions import Query
+from typing import Optional, Dict
+from lnbits.lnurl import encode as lnurl_encode # type: ignore
+from lnurl.types import LnurlScrubMetadata # type: ignore
+from sqlite3 import Row
+from pydantic import BaseModel
+
+
+class CreateScrubLinkData(BaseModel):
+ description: str
+ min: int = Query(0.01, ge=0.01)
+ max: int = Query(0.01, ge=0.01)
+ currency: str = Query(None)
+ comment_chars: int = Query(0, ge=0, lt=800)
+ webhook_url: str = Query(None)
+ success_text: str = Query(None)
+ success_url: str = Query(None)
+
+
+class ScrubLink(BaseModel):
+ id: int
+ wallet: str
+ description: str
+ min: int
+ served_meta: int
+ served_pr: int
+ webhook_url: Optional[str]
+ success_text: Optional[str]
+ success_url: Optional[str]
+ currency: Optional[str]
+ comment_chars: int
+ max: int
+
+ @classmethod
+ def from_row(cls, row: Row) -> "ScrubLink":
+ data = dict(row)
+ return cls(**data)
+
+ def lnurl(self, req: Request) -> str:
+ url = req.url_for("scrub.api_lnurl_response", link_id=self.id)
+ return lnurl_encode(url)
+
+ @property
+ def scrubay_metadata(self) -> LnurlScrubMetadata:
+ return LnurlScrubMetadata(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: Dict = parse_qs(url.query)
+ 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/lnbits/extensions/scrub/static/js/index.js b/lnbits/extensions/scrub/static/js/index.js
new file mode 100644
index 00000000..bfc8c1a6
--- /dev/null
+++ b/lnbits/extensions/scrub/static/js/index.js
@@ -0,0 +1,227 @@
+/* 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 mapScrubLink = 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() {
+ return {
+ currencies: [],
+ fiatRates: {},
+ checker: null,
+ payLinks: [],
+ payLinksTable: {
+ pagination: {
+ rowsPerPage: 10
+ }
+ },
+ formDialog: {
+ show: false,
+ fixedAmount: true,
+ data: {}
+ },
+ qrCodeDialog: {
+ show: false,
+ data: null
+ }
+ }
+ },
+ methods: {
+ getScrubLinks() {
+ LNbits.api
+ .request(
+ 'GET',
+ '/scrub/api/v1/links?all_wallets=true',
+ this.g.user.wallets[0].inkey
+ )
+ .then(response => {
+ this.payLinks = response.data.map(mapScrubLink)
+ })
+ .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.updateScrubLink(wallet, data)
+ } else {
+ this.createScrubLink(wallet, data)
+ }
+ },
+ resetFormData() {
+ this.formDialog = {
+ show: false,
+ fixedAmount: true,
+ data: {}
+ }
+ },
+ updateScrubLink(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',
+ '/scrub/api/v1/links/' + data.id,
+ wallet.adminkey,
+ values
+ )
+ .then(response => {
+ this.payLinks = _.reject(this.payLinks, obj => obj.id === data.id)
+ this.payLinks.push(mapScrubLink(response.data))
+ this.formDialog.show = false
+ this.resetFormData()
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ },
+ createScrubLink(wallet, data) {
+ LNbits.api
+ .request('POST', '/scrub/api/v1/links', wallet.adminkey, data)
+ .then(response => {
+ this.getScrubLinks()
+ this.formDialog.show = false
+ this.resetFormData()
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ },
+ deleteScrubLink(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',
+ '/scrub/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', '/scrub/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)
+ })
+ }
+ },
+ created() {
+ if (this.g.user.wallets.length) {
+ var getScrubLinks = this.getScrubLinks
+ getScrubLinks()
+ this.checker = setInterval(() => {
+ getScrubLinks()
+ }, 20000)
+ }
+ LNbits.api
+ .request('GET', '/scrub/api/v1/currencies')
+ .then(response => {
+ this.currencies = ['satoshis', ...response.data]
+ })
+ .catch(err => {
+ LNbits.utils.notifyApiError(err)
+ })
+ }
+})
diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py
new file mode 100644
index 00000000..af281e37
--- /dev/null
+++ b/lnbits/extensions/scrub/tasks.py
@@ -0,0 +1,59 @@
+import asyncio
+import json
+import httpx
+
+from lnbits.core import db as core_db
+from lnbits.core.models import Scrubment
+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)
+
+ while True:
+ payment = await invoice_queue.get()
+ await on_invoice_paid(payment)
+
+
+async def on_invoice_paid(payment: Scrubment) -> None:
+ if "scrub" != payment.extra.get("tag"):
+ # not an scrub invoice
+ 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 = 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"),
+ "scrub": pay_link.id,
+ },
+ timeout=40,
+ )
+ await mark_webhook_sent(payment, r.status_code)
+ except (httpx.ConnectError, httpx.RequestError):
+ await mark_webhook_sent(payment, -1)
+
+
+async def mark_webhook_sent(payment: Scrubment, status: int) -> None:
+ payment.extra["wh_status"] = status
+
+ await core_db.execute(
+ """
+ UPDATE apipayments SET extra = ?
+ WHERE hash = ?
+ """,
+ (json.dumps(payment.extra), payment.payment_hash),
+ )
diff --git a/lnbits/extensions/scrub/templates/lnurlp/_api_docs.html b/lnbits/extensions/scrub/templates/lnurlp/_api_docs.html
new file mode 100644
index 00000000..894e45c4
--- /dev/null
+++ b/lnbits/extensions/scrub/templates/lnurlp/_api_docs.html
@@ -0,0 +1,135 @@
+
+ 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 /scrub/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 }}api/v1/links -H "X-Api-Key: {{
+ user.wallets[0].inkey }}"
+
+ GET
+ /scrub/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 }}api/v1/links/<pay_id> -H
+ "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+ POST /scrub/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 }}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
+ /scrub/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 }}api/v1/links/<pay_id> -d
+ '{"description": <string>, "amount": <integer>}' -H
+ "Content-type: application/json" -H "X-Api-Key: {{
+ user.wallets[0].adminkey }}"
+
+ DELETE
+ /scrub/api/v1/links/<pay_id>
+ Headers
+ {"X-Api-Key": <admin_key>}
+ Returns 204 NO CONTENT
+
+ Curl example
+ curl -X DELETE {{ request.base_url }}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 }}
+