Merge pull request #455 from arcbtc/lnurlpayout

Adds LNURLPayout: auto-payout to an LNURL
This commit is contained in:
Arc 2021-12-19 23:15:07 +00:00 committed by GitHub
commit 6b86506a64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 671 additions and 3 deletions

View File

@ -466,11 +466,11 @@ async def api_lnurlscan(code: str):
@core_app.post("/api/v1/payments/decode")
async def api_payments_decode(data: str = Query(None)):
try:
if g.data["data"][:5] == "LNURL":
url = lnurl.decode(g.data["data"])
if data["data"][:5] == "LNURL":
url = lnurl.decode(data["data"])
return {"domain": url}
else:
invoice = bolt11.decode(g.data["data"])
invoice = bolt11.decode(data["data"])
return {
"payment_hash": invoice.payment_hash,
"amount_msat": invoice.amount_msat,

View File

@ -0,0 +1,3 @@
# LNURLPayOut
## Auto-dump a wallets funds to an LNURLpay

View File

@ -0,0 +1,22 @@
import asyncio
from fastapi import APIRouter
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_lnurlpayout")
lnurlpayout_ext: APIRouter = APIRouter(prefix="/lnurlpayout", tags=["lnurlpayout"])
def lnurlpayout_renderer():
return template_renderer(["lnbits/extensions/lnurlpayout/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def lnurlpayout_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -0,0 +1,6 @@
{
"name": "LNURLPayout",
"short_description": "Autodump wallet funds to LNURLpay",
"icon": "exit_to_app",
"contributors": ["arcbtc"]
}

View File

@ -0,0 +1,45 @@
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import lnurlpayout, CreateLnurlPayoutData
async def create_lnurlpayout(wallet_id: str, admin_key: str, data: CreateLnurlPayoutData) -> lnurlpayout:
lnurlpayout_id = urlsafe_short_hash()
await db.execute(
"""
INSERT INTO lnurlpayout.lnurlpayouts (id, title, wallet, admin_key, lnurlpay, threshold)
VALUES (?, ?, ?, ?, ?, ?)
""",
(lnurlpayout_id, data.title, wallet_id, admin_key, data.lnurlpay, data.threshold),
)
lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
assert lnurlpayout, "Newly created lnurlpayout couldn't be retrieved"
return lnurlpayout
async def get_lnurlpayout(lnurlpayout_id: str) -> Optional[lnurlpayout]:
row = await db.fetchone("SELECT * FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,))
return lnurlpayout(**row) if row else None
async def get_lnurlpayout_from_wallet(wallet_id: str) -> Optional[lnurlpayout]:
row = await db.fetchone("SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet = ?", (wallet_id,))
return lnurlpayout(**row) if row else None
async def get_lnurlpayouts(wallet_ids: Union[str, List[str]]) -> List[lnurlpayout]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM lnurlpayout.lnurlpayouts WHERE wallet IN ({q})", (*wallet_ids,)
)
return [lnurlpayout(**row) if row else None for row in rows]
async def delete_lnurlpayout(lnurlpayout_id: str) -> None:
await db.execute("DELETE FROM lnurlpayout.lnurlpayouts WHERE id = ?", (lnurlpayout_id,))

View File

@ -0,0 +1,16 @@
async def m001_initial(db):
"""
Initial lnurlpayouts table.
"""
await db.execute(
"""
CREATE TABLE lnurlpayout.lnurlpayouts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
wallet TEXT NOT NULL,
admin_key TEXT NOT NULL,
lnurlpay TEXT NOT NULL,
threshold INT NOT NULL
);
"""
)

View File

@ -0,0 +1,16 @@
from sqlite3 import Row
from pydantic import BaseModel
class CreateLnurlPayoutData(BaseModel):
title: str
lnurlpay: str
threshold: int
class lnurlpayout(BaseModel):
id: str
title: str
wallet: str
admin_key: str
lnurlpay: str
threshold: int

View File

@ -0,0 +1,70 @@
import asyncio
import json
import httpx
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from lnbits.core.views.api import api_wallet
from lnbits.core.crud import get_wallet
from lnbits.core.views.api import api_payment, api_payments_decode, pay_invoice
from .crud import get_lnurlpayout, get_lnurlpayout_from_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:
try:
# Check its got a payout associated with it
lnurlpayout_link = await get_lnurlpayout_from_wallet(payment.wallet_id)
if lnurlpayout_link:
# Check the wallet balance is more than the threshold
wallet = await get_wallet(lnurlpayout_link.wallet)
if wallet.balance < lnurlpayout_link.threshold + (lnurlpayout_link.threshold*0.02):
return
# Get the invoice from the LNURL to pay
async with httpx.AsyncClient() as client:
try:
url = await api_payments_decode({"data": lnurlpayout_link.lnurlpay})
if str(url["domain"])[0:4] != "http":
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="LNURL broken")
try:
r = await client.get(
str(url["domain"]),
timeout=40,
)
res = r.json()
try:
r = await client.get(
res["callback"] + "?amount=" + str(int((wallet.balance - wallet.balance*0.02) * 1000)),
timeout=40,
)
res = r.json()
try:
await pay_invoice(
wallet_id=payment.wallet_id,
payment_request=res["pr"],
extra={"tag": "lnurlpayout"},
)
return
except:
pass
except:
return
except (httpx.ConnectError, httpx.RequestError):
return
except Exception:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout")
except:
return

View File

@ -0,0 +1,118 @@
<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 lnurlpayout">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/lnurlpayout/api/v1/lnurlpayouts</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;lnurlpayout_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Create a lnurlpayout"
>
<q-card>
<q-card-section>
<code
><span class="text-green">POST</span>
/lnurlpayout/api/v1/lnurlpayouts</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>
<code
>{"name": &lt;string&gt;, "currency": &lt;string*ie USD*&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"currency": &lt;string&gt;, "id": &lt;string&gt;, "name":
&lt;string&gt;, "wallet": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}lnurlpayout/api/v1/lnurlpayouts -d
'{"name": &lt;string&gt;, "currency": &lt;string&gt;}' -H
"Content-type: application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a lnurlpayout"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/lnurlpayout/api/v1/lnurlpayouts/&lt;lnurlpayout_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.base_url
}}lnurlpayout/api/v1/lnurlpayouts/&lt;lnurlpayout_id&gt; -H
"X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Check lnurlpayout"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/lnurlpayout/api/v1/lnurlpayouts/&lt;lnurlpayout_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 200 OK (application/json)
</h5>
<code>[&lt;lnurlpayout_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}lnurlpayout/api/v1/lnurlpayouts/&lt;lnurlpayout_id&gt; -H
"X-Api-Key: &lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,268 @@
{% 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-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
>New LNURLPayout</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">LNURLPayout</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="lnurlpayouts"
row-key="id"
:columns="lnurlpayoutsTable.columns"
:pagination.sync="lnurlpayoutsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<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 v-for="col in props.cols" :key="col.name" :props="props">
<a v-if="col.label == 'LNURLPay'" @click="copyText(col.value)"
><q-tooltip>Click to copy LNURL</q-tooltip>{{
col.value.substring(0, 40) }}...</a
>
<div v-else-if="col.label == 'Threshold'">
{{ col.value }} Sats
</div>
<div v-else>{{ col.value.substring(0, 40) }}</div>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deletelnurlpayout(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">
{{SITE_TITLE}} LNURLPayout extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "lnurlpayout/_api_docs.html" %}
<q-separator></q-separator>
</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" style="width: 500px">
<q-form @submit="createlnurlpayout" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.title"
label="Title"
placeholder="Title"
type="text"
></q-input>
<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.lnurlpay"
label="LNURLPay"
placeholder="LNURLPay"
type="text"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.threshold"
label="Threshold (100k sats max)"
placeholder="Threshold"
type="number"
max="100000"
></q-input>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.threshold == null"
type="submit"
>Create LNURLPayout</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var maplnurlpayout = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.lnurlpayout = ['/lnurlpayout/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
lnurlpayouts: [],
lnurlpayoutsTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'title', align: 'left', label: 'Title', field: 'title'},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'lnurlpay',
align: 'left',
label: 'LNURLPay',
field: 'lnurlpay'
},
{
name: 'threshold',
align: 'left',
label: 'Threshold',
field: 'threshold'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {}
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
},
getlnurlpayouts: function () {
var self = this
LNbits.api
.request(
'GET',
'/lnurlpayout/api/v1/lnurlpayouts?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.lnurlpayouts = response.data.map(function (obj) {
return maplnurlpayout(obj)
})
})
},
createlnurlpayout: function () {
var data = {
title: this.formDialog.data.title,
lnurlpay: this.formDialog.data.lnurlpay,
threshold: this.formDialog.data.threshold
}
var self = this
LNbits.api
.request(
'POST',
'/lnurlpayout/api/v1/lnurlpayouts',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
console.log(data)
self.lnurlpayouts.push(maplnurlpayout(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deletelnurlpayout: function (lnurlpayoutId) {
var self = this
var lnurlpayout = _.findWhere(this.lnurlpayouts, {id: lnurlpayoutId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this lnurlpayout?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/lnurlpayout/api/v1/lnurlpayouts/' + lnurlpayoutId,
_.findWhere(self.g.user.wallets, {id: lnurlpayout.wallet})
.adminkey
)
.then(function (response) {
self.lnurlpayouts = _.reject(self.lnurlpayouts, function (obj) {
return obj.id == lnurlpayoutId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(
this.lnurlpayoutsTable.columns,
this.lnurlpayouts
)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getlnurlpayouts()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,22 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import lnurlpayout_ext, lnurlpayout_renderer
from .crud import get_lnurlpayout
templates = Jinja2Templates(directory="templates")
@lnurlpayout_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return lnurlpayout_renderer().TemplateResponse(
"lnurlpayout/index.html", {"request": request, "user": user.dict()}
)

View File

@ -0,0 +1,82 @@
from http import HTTPStatus
from fastapi import Query
from fastapi.params import Depends
from starlette.exceptions import HTTPException
from lnbits.core.crud import get_user, get_payments
from lnbits.core.services import create_invoice
from lnbits.core.views.api import api_payment, api_payments_decode
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import lnurlpayout_ext
from .crud import create_lnurlpayout, delete_lnurlpayout, get_lnurlpayout, get_lnurlpayouts, get_lnurlpayout_from_wallet
from .models import lnurlpayout, CreateLnurlPayoutData
from .tasks import on_invoice_paid
@lnurlpayout_ext.get("/api/v1/lnurlpayouts", status_code=HTTPStatus.OK)
async def api_lnurlpayouts(
all_wallets: bool = Query(None), wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = [wallet.wallet.id]
if all_wallets:
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
return [lnurlpayout.dict() for lnurlpayout in await get_lnurlpayouts(wallet_ids)]
@lnurlpayout_ext.post("/api/v1/lnurlpayouts", status_code=HTTPStatus.CREATED)
async def api_lnurlpayout_create(
data: CreateLnurlPayoutData, wallet: WalletTypeInfo = Depends(get_key_type)
):
if await get_lnurlpayout_from_wallet(wallet.wallet.id):
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Wallet already has lnurlpayout set")
return
url = await api_payments_decode({"data": data.lnurlpay})
if "domain" not in url:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="LNURL could not be decoded")
return
if str(url["domain"])[0:4] != "http":
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not valid LNURL")
return
lnurlpayout = await create_lnurlpayout(wallet_id=wallet.wallet.id, admin_key=wallet.wallet.adminkey, data=data)
if not lnurlpayout:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Failed to save LNURLPayout")
return
return lnurlpayout.dict()
@lnurlpayout_ext.delete("/api/v1/lnurlpayouts/{lnurlpayout_id}")
async def api_lnurlpayout_delete(
lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
if not lnurlpayout:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="lnurlpayout does not exist."
)
if lnurlpayout.wallet != wallet.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your lnurlpayout.")
await delete_lnurlpayout(lnurlpayout_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK)
async def api_lnurlpayout_check(
lnurlpayout_id: str, wallet: WalletTypeInfo = Depends(get_key_type)
):
lnurlpayout = await get_lnurlpayout(lnurlpayout_id)
payments = await get_payments(
wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
)
result = await on_invoice_paid(payments[0])
return
# get payouts func
# lnurlpayouts = await get_lnurlpayouts(wallet_ids)
# for lnurlpayout in lnurlpayouts:
# payments = await get_payments(
# wallet_id=lnurlpayout.wallet, complete=True, pending=False, outgoing=True, incoming=True
# )
# await on_invoice_paid(payments[0])