diff --git a/lnbits/extensions/scrub/README.md b/lnbits/extensions/scrub/README.md
new file mode 100644
index 00000000..680c5e6d
--- /dev/null
+++ b/lnbits/extensions/scrub/README.md
@@ -0,0 +1,28 @@
+# Scrub
+
+## Automatically forward funds (Scrub) that get paid to the wallet to an LNURLpay or Lightning Address
+
+SCRUB is a small but handy extension that allows a user to take advantage of all the functionalities inside **LNbits** and upon a payment received to your LNbits wallet, automatically forward it to your desired wallet via LNURL or LNAddress!
+
+[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
+
+## Usage
+
+1. Create an scrub (New Scrub link)\
+ ![create scrub](https://i.imgur.com/LUeNkzM.jpg)
+
+ - select the wallet to be _scrubbed_
+ - make a small description
+ - enter either an LNURL pay or a lightning address
+
+ Make sure your LNURL or LNaddress is correct!
+
+2. A new scrub will show on the _Scrub links_ section\
+ ![scrub](https://i.imgur.com/LNoFkeu.jpg)
+
+ - only one scrub can be created for each wallet!
+ - You can _edit_ or _delete_ the Scrub at any time\
+ ![edit scrub](https://i.imgur.com/Qu65lGG.jpg)
+
+3. On your wallet, you'll see a transaction of a payment received and another right after it as apayment sent, marked with **#scrubed**\
+ ![wallet view](https://i.imgur.com/S6EWWCP.jpg)
diff --git a/lnbits/extensions/scrub/__init__.py b/lnbits/extensions/scrub/__init__.py
new file mode 100644
index 00000000..777a7c3f
--- /dev/null
+++ b/lnbits/extensions/scrub/__init__.py
@@ -0,0 +1,34 @@
+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 .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..df9e0038
--- /dev/null
+++ b/lnbits/extensions/scrub/config.json
@@ -0,0 +1,6 @@
+{
+ "name": "Scrub",
+ "short_description": "Pass payments to LNURLp/LNaddress",
+ "icon": "send",
+ "contributors": ["arcbtc", "talvasconcelos"]
+}
diff --git a/lnbits/extensions/scrub/crud.py b/lnbits/extensions/scrub/crud.py
new file mode 100644
index 00000000..1772a8c5
--- /dev/null
+++ b/lnbits/extensions/scrub/crud.py
@@ -0,0 +1,80 @@
+from typing import List, Optional, Union
+
+from lnbits.helpers import urlsafe_short_hash
+
+from . import db
+from .models import CreateScrubLink, ScrubLink
+
+
+async def create_scrub_link(data: CreateScrubLink) -> ScrubLink:
+ scrub_id = urlsafe_short_hash()
+ await db.execute(
+ """
+ INSERT INTO scrub.scrub_links (
+ id,
+ wallet,
+ description,
+ payoraddress
+ )
+ VALUES (?, ?, ?, ?)
+ """,
+ (
+ scrub_id,
+ data.wallet,
+ data.description,
+ data.payoraddress,
+ ),
+ )
+ link = await get_scrub_link(scrub_id)
+ assert link, "Newly created link couldn't be retrieved"
+ return link
+
+
+async def get_scrub_link(link_id: str) -> Optional[ScrubLink]:
+ row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,))
+ return ScrubLink(**row) if row else None
+
+
+async def get_scrub_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.scrub_links WHERE wallet IN ({q})
+ ORDER BY id
+ """,
+ (*wallet_ids,),
+ )
+ return [ScrubLink(**row) for row in rows]
+
+
+async def update_scrub_link(link_id: int, **kwargs) -> Optional[ScrubLink]:
+ q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
+ await db.execute(
+ f"UPDATE scrub.scrub_links SET {q} WHERE id = ?",
+ (*kwargs.values(), link_id),
+ )
+ row = await db.fetchone("SELECT * FROM scrub.scrub_links WHERE id = ?", (link_id,))
+ return ScrubLink(**row) if row else None
+
+
+async def delete_scrub_link(link_id: int) -> None:
+ await db.execute("DELETE FROM scrub.scrub_links WHERE id = ?", (link_id,))
+
+
+async def get_scrub_by_wallet(wallet_id) -> Optional[ScrubLink]:
+ row = await db.fetchone(
+ "SELECT * from scrub.scrub_links WHERE wallet = ?",
+ (wallet_id,),
+ )
+ return ScrubLink(**row) if row else None
+
+
+async def unique_scrubed_wallet(wallet_id):
+ (row,) = await db.fetchone(
+ "SELECT COUNT(wallet) FROM scrub.scrub_links WHERE wallet = ?",
+ (wallet_id,),
+ )
+ return row
diff --git a/lnbits/extensions/scrub/migrations.py b/lnbits/extensions/scrub/migrations.py
new file mode 100644
index 00000000..f8f2ba43
--- /dev/null
+++ b/lnbits/extensions/scrub/migrations.py
@@ -0,0 +1,14 @@
+async def m001_initial(db):
+ """
+ Initial scrub table.
+ """
+ await db.execute(
+ f"""
+ CREATE TABLE scrub.scrub_links (
+ id TEXT PRIMARY KEY,
+ wallet TEXT NOT NULL,
+ description TEXT NOT NULL,
+ payoraddress TEXT NOT NULL
+ );
+ """
+ )
diff --git a/lnbits/extensions/scrub/models.py b/lnbits/extensions/scrub/models.py
new file mode 100644
index 00000000..db05e4f1
--- /dev/null
+++ b/lnbits/extensions/scrub/models.py
@@ -0,0 +1,28 @@
+from sqlite3 import Row
+
+from pydantic import BaseModel
+from starlette.requests import Request
+
+from lnbits.lnurl import encode as lnurl_encode # type: ignore
+
+
+class CreateScrubLink(BaseModel):
+ wallet: str
+ description: str
+ payoraddress: str
+
+
+class ScrubLink(BaseModel):
+ id: str
+ wallet: str
+ description: str
+ payoraddress: str
+
+ @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)
diff --git a/lnbits/extensions/scrub/static/js/index.js b/lnbits/extensions/scrub/static/js/index.js
new file mode 100644
index 00000000..43990792
--- /dev/null
+++ b/lnbits/extensions/scrub/static/js/index.js
@@ -0,0 +1,143 @@
+/* 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 {
+ checker: null,
+ payLinks: [],
+ payLinksTable: {
+ pagination: {
+ rowsPerPage: 10
+ }
+ },
+ formDialog: {
+ show: false,
+ 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()
+ },
+ openUpdateDialog(linkId) {
+ const link = _.findWhere(this.payLinks, {id: linkId})
+
+ this.formDialog.data = _.clone(link._data)
+ this.formDialog.show = true
+ },
+ sendFormData() {
+ const wallet = _.findWhere(this.g.user.wallets, {
+ id: this.formDialog.data.wallet
+ })
+ let data = Object.freeze(this.formDialog.data)
+ console.log(wallet, data)
+
+ if (data.id) {
+ this.updateScrubLink(wallet, data)
+ } else {
+ this.createScrubLink(wallet, data)
+ }
+ },
+ resetFormData() {
+ this.formDialog = {
+ show: false,
+ data: {}
+ }
+ },
+ updateScrubLink(wallet, data) {
+ LNbits.api
+ .request('PUT', '/scrub/api/v1/links/' + data.id, wallet.adminkey, data)
+ .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 => {
+ console.log('RES', 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)
+ })
+ })
+ }
+ },
+ created() {
+ if (this.g.user.wallets.length) {
+ var getScrubLinks = this.getScrubLinks
+ getScrubLinks()
+ }
+ }
+})
diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py
new file mode 100644
index 00000000..87e1364b
--- /dev/null
+++ b/lnbits/extensions/scrub/tasks.py
@@ -0,0 +1,85 @@
+import asyncio
+import json
+from http import HTTPStatus
+from urllib.parse import urlparse
+
+import httpx
+from fastapi import HTTPException
+
+from lnbits import bolt11
+from lnbits.core.models import Payment
+from lnbits.core.services import pay_invoice
+from lnbits.tasks import register_invoice_listener
+
+from .crud import get_scrub_by_wallet
+
+
+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: Payment) -> None:
+ # (avoid loops)
+ if "scrubed" == payment.extra.get("tag"):
+ # already scrubbed
+ return
+
+ scrub_link = await get_scrub_by_wallet(payment.wallet_id)
+
+ if not scrub_link:
+ return
+
+ from lnbits.core.views.api import api_lnurlscan
+
+ # DECODE LNURLP OR LNADDRESS
+ data = await api_lnurlscan(scrub_link.payoraddress)
+
+ # I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267
+ domain = urlparse(data["callback"]).netloc
+
+ async with httpx.AsyncClient() as client:
+ try:
+ r = await client.get(
+ data["callback"],
+ params={"amount": payment.amount},
+ timeout=40,
+ )
+ if r.is_error:
+ raise httpx.ConnectError
+ except (httpx.ConnectError, httpx.RequestError):
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"Failed to connect to {domain}.",
+ )
+
+ params = json.loads(r.text)
+ if params.get("status") == "ERROR":
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"{domain} said: '{params.get('reason', '')}'",
+ )
+
+ invoice = bolt11.decode(params["pr"])
+ if invoice.amount_msat != payment.amount:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.",
+ )
+
+ payment_hash = await pay_invoice(
+ wallet_id=payment.wallet_id,
+ payment_request=params["pr"],
+ description=data["description"],
+ extra={"tag": "scrubed"},
+ )
+
+ return {
+ "payment_hash": payment_hash,
+ # maintain backwards compatibility with API clients:
+ "checking_id": payment_hash,
+ }
diff --git a/lnbits/extensions/scrub/templates/scrub/_api_docs.html b/lnbits/extensions/scrub/templates/scrub/_api_docs.html
new file mode 100644
index 00000000..ae3f44d8
--- /dev/null
+++ b/lnbits/extensions/scrub/templates/scrub/_api_docs.html
@@ -0,0 +1,136 @@
+
+ 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 }}scrub/api/v1/links?all_wallets=true
+ -H "X-Api-Key: {{ user.wallets[0].inkey }}"
+
+ GET
+ /scrub/api/v1/links/<scrub_id>
+ Headers
+ {"X-Api-Key": <invoice_key>}
+ Body (application/json)
+
+ Returns 200 OK (application/json)
+
+ {"id": <string>, "wallet": <string>, "description":
+ <string>, "payoraddress": <string>}
+ Curl example
+ curl -X GET {{ request.base_url }}scrub/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)
+ {"wallet": <string>, "description": <string>,
+ "payoraddress": <string>}
+
+ Returns 201 CREATED (application/json)
+
+ {"id": <string>, "wallet": <string>, "description":
+ <string>, "payoraddress": <string>}
+ Curl example
+ curl -X POST {{ request.base_url }}scrub/api/v1/links -d '{"wallet":
+ <string>, "description": <string>, "payoraddress":
+ <string>}' -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)
+ {"wallet": <string>, "description": <string>,
+ "payoraddress": <string>}
+
+ Returns 200 OK (application/json)
+
+ {"id": <string>, "wallet": <string>, "description":
+ <string>, "payoraddress": <string>}
+ Curl example
+ curl -X PUT {{ request.base_url }}scrub/api/v1/links/<pay_id>
+ -d '{"wallet": <string>, "description": <string>,
+ "payoraddress": <string>}' -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
+ }}scrub/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.
+