Merge pull request #1045 from lnbits/lnurldevidefix

Adds bitcoinSwitch support to LNURLDevices
This commit is contained in:
Arc
2022-10-07 23:59:45 +01:00
committed by GitHub
10 changed files with 303 additions and 32 deletions

View File

@@ -1,6 +1,8 @@
import asyncio
from fastapi import APIRouter from fastapi import APIRouter
from lnbits.db import Database from lnbits.db import Database
from lnbits.tasks import catch_everything_and_restart
from lnbits.helpers import template_renderer from lnbits.helpers import template_renderer
db = Database("ext_lnurldevice") db = Database("ext_lnurldevice")
@@ -12,6 +14,12 @@ def lnurldevice_renderer():
return template_renderer(["lnbits/extensions/lnurldevice/templates"]) return template_renderer(["lnbits/extensions/lnurldevice/templates"])
from .tasks import wait_for_paid_invoices
from .lnurl import * # noqa from .lnurl import * # noqa
from .views import * # noqa from .views import * # noqa
from .views_api import * # noqa from .views_api import * # noqa
def lnurldevice_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@@ -22,9 +22,10 @@ async def create_lnurldevice(
wallet, wallet,
currency, currency,
device, device,
profit profit,
amount
) )
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
lnurldevice_id, lnurldevice_id,
@@ -34,6 +35,7 @@ async def create_lnurldevice(
data.currency, data.currency,
data.device, data.device,
data.profit, data.profit,
data.amount,
), ),
) )
return await get_lnurldevice(lnurldevice_id) return await get_lnurldevice(lnurldevice_id)

View File

@@ -102,7 +102,32 @@ async def lnurl_v1_params(
if device.device == "atm": if device.device == "atm":
if paymentcheck: if paymentcheck:
return {"status": "ERROR", "reason": f"Payment already claimed"} return {"status": "ERROR", "reason": f"Payment already claimed"}
if device.device == "switch":
price_msat = (
await fiat_amount_as_satoshis(float(device.profit), device.currency)
if device.currency != "sat"
else amount_in_cent
) * 1000
lnurldevicepayment = await create_lnurldevicepayment(
deviceid=device.id,
payload="bla",
sats=price_msat,
pin=1,
payhash="bla",
)
if not lnurldevicepayment:
return {"status": "ERROR", "reason": "Could not create payment."}
return {
"tag": "payRequest",
"callback": request.url_for(
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
),
"minSendable": price_msat,
"maxSendable": price_msat,
"metadata": await device.lnurlpay_metadata(),
}
if len(p) % 4 > 0: if len(p) % 4 > 0:
p += "=" * (4 - (len(p) % 4)) p += "=" * (4 - (len(p) % 4))
@@ -184,22 +209,42 @@ async def lnurl_callback(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found." status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found."
) )
if pr: if device.device == "atm":
if lnurldevicepayment.id != k1: if not pr:
return {"status": "ERROR", "reason": "Bad K1"} raise HTTPException(
if lnurldevicepayment.payhash != "payment_hash": status_code=HTTPStatus.FORBIDDEN, detail="No payment request"
return {"status": "ERROR", "reason": f"Payment already claimed"} )
else:
if lnurldevicepayment.id != k1:
return {"status": "ERROR", "reason": "Bad K1"}
if lnurldevicepayment.payhash != "payment_hash":
return {"status": "ERROR", "reason": f"Payment already claimed"}
lnurldevicepayment = await update_lnurldevicepayment( lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
) )
await pay_invoice( await pay_invoice(
wallet_id=device.wallet,
payment_request=pr,
max_sat=lnurldevicepayment.sats / 1000,
extra={"tag": "withdraw"},
)
return {"status": "OK"}
if device.device == "switch":
payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
payment_request=pr, amount=lnurldevicepayment.sats / 1000,
max_sat=lnurldevicepayment.sats / 1000, memo=device.title + "-" + lnurldevicepayment.id,
extra={"tag": "withdraw"}, unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
extra={"tag": "Switch", "id": paymentid, "time": device.amount},
) )
return {"status": "OK"} lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=payment_hash
)
return {
"pr": payment_request,
"routes": [],
}
payment_hash, payment_request = await create_invoice( payment_hash, payment_request = await create_invoice(
wallet_id=device.wallet, wallet_id=device.wallet,
@@ -221,5 +266,3 @@ async def lnurl_callback(
}, },
"routes": [], "routes": [],
} }
return resp.dict()

View File

@@ -79,3 +79,12 @@ async def m002_redux(db):
) )
except: except:
return return
async def m003_redux(db):
"""
Add 'meta' for storing various metadata about the wallet
"""
await db.execute(
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount INT DEFAULT 0;"
)

View File

@@ -17,6 +17,7 @@ class createLnurldevice(BaseModel):
currency: str currency: str
device: str device: str
profit: float profit: float
amount: int
class lnurldevices(BaseModel): class lnurldevices(BaseModel):
@@ -27,15 +28,14 @@ class lnurldevices(BaseModel):
currency: str currency: str
device: str device: str
profit: float profit: float
amount: int
timestamp: str timestamp: str
def from_row(cls, row: Row) -> "lnurldevices": def from_row(cls, row: Row) -> "lnurldevices":
return cls(**dict(row)) return cls(**dict(row))
def lnurl(self, req: Request) -> Lnurl: def lnurl(self, req: Request) -> Lnurl:
url = req.url_for( url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
"lnurldevice.lnurl_response", device_id=self.id, _external=True
)
return lnurl_encode(url) return lnurl_encode(url)
async def lnurlpay_metadata(self) -> LnurlPayMetadata: async def lnurlpay_metadata(self) -> LnurlPayMetadata:

View File

@@ -0,0 +1,40 @@
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.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
from .views import updater
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) -> None:
# (avoid loops)
if "Switch" == payment.extra.get("tag"):
lnurldevicepayment = await get_lnurldevicepayment(payment.extra.get("id"))
if not lnurldevicepayment:
return
if lnurldevicepayment.payhash == "used":
return
lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
)
return await updater(lnurldevicepayment.deviceid)
return

View File

@@ -1,10 +1,10 @@
<q-card> <q-card>
<q-card-section> <q-card-section>
<p> <p>
Register LNURLDevice devices to receive payments in your LNbits wallet.<br /> For LNURL based Points of Sale, ATMs, and relay devices<br />
Build your own here Such as the LNPoS
<a href="https://github.com/arcbtc/bitcoinpos" <a href="https://lnbits.github.io/lnpos"
>https://github.com/arcbtc/bitcoinpos</a > https://lnbits.github.io/lnpos</a
><br /> ><br />
<small> <small>
Created by, <a href="https://github.com/benarc">Ben Arc</a></small Created by, <a href="https://github.com/benarc">Ben Arc</a></small

View File

@@ -51,6 +51,7 @@
<q-tr :props="props"> <q-tr :props="props">
<q-th style="width: 5%"></q-th> <q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th> <q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th <q-th
v-for="col in props.cols" v-for="col in props.cols"
@@ -91,6 +92,20 @@
<q-tooltip> LNURLDevice Settings </q-tooltip> <q-tooltip> LNURLDevice Settings </q-tooltip>
</q-btn> </q-btn>
</q-td> </q-td>
<q-td>
<q-btn
v-if="props.row.device == 'switch'"
:disable="protocol == 'http:'"
flat
unelevated
dense
size="xs"
icon="visibility"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openQrCodeDialog(props.row.id)"
><q-tooltip v-if="protocol == 'http:'"> LNURLs only work over HTTPS </q-tooltip><q-tooltip v-else> view LNURL </q-tooltip></q-btn
>
</q-td>
<q-td <q-td
v-for="col in props.cols" v-for="col in props.cols"
:key="col.name" :key="col.name"
@@ -132,7 +147,21 @@
class="q-pa-lg q-pt-xl lnbits__dialog-card" class="q-pa-lg q-pt-xl lnbits__dialog-card"
> >
<div class="text-h6">LNURLDevice device string</div> <div class="text-h6">LNURLDevice device string</div>
<center>
<q-btn <q-btn
v-if="settingsDialog.data.device == 'switch'"
dense
outline
unelevated
color="primary"
size="md"
@click="copyText(wslocation + '/lnurldevice/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
>{% raw
%}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{% endraw
%}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn>
<q-btn
v-else
dense dense
outline outline
unelevated unelevated
@@ -145,7 +174,7 @@
{{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw {{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw
%}<q-tooltip> Click to copy URL </q-tooltip> %}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn> </q-btn>
</center>
<div class="text-subtitle2"> <div class="text-subtitle2">
<small> </small> <small> </small>
</div> </div>
@@ -191,6 +220,7 @@
label="Type of device" label="Type of device"
></q-option-group> ></q-option-group>
<q-input <q-input
v-if="formDialoglnurldevice.data.device != 'switch'"
filled filled
dense dense
v-model.trim="formDialoglnurldevice.data.profit" v-model.trim="formDialoglnurldevice.data.profit"
@@ -198,6 +228,30 @@
max="90" max="90"
label="Profit margin (% added to invoices/deducted from faucets)" label="Profit margin (% added to invoices/deducted from faucets)"
></q-input> ></q-input>
<div v-else>
<q-input
ref="setAmount"
filled
dense
v-model.trim="formDialoglnurldevice.data.profit"
class="q-pb-md"
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
:mask="'#.##'"
fill-mask="0"
reverse-fill-mask
:step="'0.01'"
value="0.00"
></q-input>
<q-input
filled
dense
v-model.trim="formDialoglnurldevice.data.amount"
type="number"
value="1000"
label="milesecs to turn Switch on for (1sec = 1000ms)"
></q-input>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
@@ -225,6 +279,34 @@
</q-form> </q-form>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="qrCodeDialog.show" position="top">
<q-card v-if="qrCodeDialog.data" class="q-pa-lg lnbits__dialog-card">
<q-responsive :ratio="1" class="q-mx-xl q-mb-md">
<qrcode
:value="qrCodeDialog.data.url + '/?lightning=' + qrCodeDialog.data.lnurl"
:options="{width: 800}"
class="rounded-borders"
></qrcode>
{% raw %}
</q-responsive>
<p style="word-break: break-all">
<strong>ID:</strong> {{ qrCodeDialog.data.id }}<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 v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div> </div>
{% endblock %} {% block scripts %} {{ window_vars(user) }} {% endblock %} {% block scripts %} {{ window_vars(user) }}
@@ -252,7 +334,9 @@
mixins: [windowMixin], mixins: [windowMixin],
data: function () { data: function () {
return { return {
protocol: window.location.protocol,
location: window.location.hostname, location: window.location.hostname,
wslocation: window.location.hostname,
filter: '', filter: '',
currency: 'USD', currency: 'USD',
lnurldeviceLinks: [], lnurldeviceLinks: [],
@@ -265,6 +349,10 @@
{ {
label: 'ATM', label: 'ATM',
value: 'atm' value: 'atm'
},
{
label: 'Switch',
value: 'switch'
} }
], ],
lnurldevicesTable: { lnurldevicesTable: {
@@ -333,7 +421,8 @@
show_ack: false, show_ack: false,
show_price: 'None', show_price: 'None',
device: 'pos', device: 'pos',
profit: 2, profit: 0,
amount: 1,
title: '' title: ''
} }
}, },
@@ -344,6 +433,14 @@
} }
}, },
methods: { methods: {
openQrCodeDialog: function (lnurldevice_id) {
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {id: lnurldevice_id})
console.log(lnurldevice)
this.qrCodeDialog.data = _.clone(lnurldevice)
this.qrCodeDialog.data.url =
window.location.protocol + '//' + window.location.host
this.qrCodeDialog.show = true
},
cancellnurldevice: function (data) { cancellnurldevice: function (data) {
var self = this var self = this
self.formDialoglnurldevice.show = false self.formDialoglnurldevice.show = false
@@ -400,6 +497,7 @@
.then(function (response) { .then(function (response) {
if (response.data) { if (response.data) {
self.lnurldeviceLinks = response.data.map(maplnurldevice) self.lnurldeviceLinks = response.data.map(maplnurldevice)
console.log(response.data)
} }
}) })
.catch(function (error) { .catch(function (error) {
@@ -519,6 +617,10 @@
'//', '//',
window.location.host window.location.host
].join('') ].join('')
self.wslocation = [
'ws://',
window.location.host
].join('')
LNbits.api LNbits.api
.request('GET', '/api/v1/currencies') .request('GET', '/api/v1/currencies')
.then(response => { .then(response => {

View File

@@ -1,11 +1,13 @@
from http import HTTPStatus from http import HTTPStatus
from io import BytesIO
from fastapi import Request import pyqrcode
from fastapi import Request, WebSocket, WebSocketDisconnect
from fastapi.param_functions import Query from fastapi.param_functions import Query
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse from starlette.responses import HTMLResponse, StreamingResponse
from lnbits.core.crud import update_payment_status from lnbits.core.crud import update_payment_status
from lnbits.core.models import User from lnbits.core.models import User
@@ -51,3 +53,58 @@ async def displaypin(request: Request, paymentid: str = Query(None)):
"lnurldevice/error.html", "lnurldevice/error.html",
{"request": request, "pin": "filler", "not_paid": True}, {"request": request, "pin": "filler", "not_paid": True},
) )
@lnurldevice_ext.get("/img/{lnurldevice_id}", response_class=StreamingResponse)
async def img(request: Request, lnurldevice_id):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
)
return lnurldevice.lnurl(request)
##################WEBSOCKET ROUTES########################
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, lnurldevice_id: str):
await websocket.accept()
websocket.id = lnurldevice_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, lnurldevice_id: str):
for connection in self.active_connections:
if connection.id == lnurldevice_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@lnurldevice_ext.websocket("/ws/{lnurldevice_id}", name="lnurldevice.lnurldevice_by_id")
async def websocket_endpoint(websocket: WebSocket, lnurldevice_id: str):
await manager.connect(websocket, lnurldevice_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(lnurldevice_id):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
return
await manager.send_personal_message(f"{lnurldevice.amount}", lnurldevice_id)

View File

@@ -32,32 +32,42 @@ async def api_list_currencies_available():
@lnurldevice_ext.post("/api/v1/lnurlpos") @lnurldevice_ext.post("/api/v1/lnurlpos")
@lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}")
async def api_lnurldevice_create_or_update( async def api_lnurldevice_create_or_update(
req: Request,
data: createLnurldevice, data: createLnurldevice,
wallet: WalletTypeInfo = Depends(require_admin_key), wallet: WalletTypeInfo = Depends(require_admin_key),
lnurldevice_id: str = Query(None), lnurldevice_id: str = Query(None),
): ):
if not lnurldevice_id: if not lnurldevice_id:
lnurldevice = await create_lnurldevice(data) lnurldevice = await create_lnurldevice(data)
return lnurldevice.dict() return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
else: else:
lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id) lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
return lnurldevice.dict() return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
@lnurldevice_ext.get("/api/v1/lnurlpos") @lnurldevice_ext.get("/api/v1/lnurlpos")
async def api_lnurldevices_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)): async def api_lnurldevices_retrieve(
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
try: try:
return [ return [
{**lnurldevice.dict()} for lnurldevice in await get_lnurldevices(wallet_ids) {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
for lnurldevice in await get_lnurldevices(wallet_ids)
] ]
except: except:
return "" try:
return [
{**lnurldevice.dict()}
for lnurldevice in await get_lnurldevices(wallet_ids)
]
except:
return ""
@lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}")
async def api_lnurldevice_retrieve( async def api_lnurldevice_retrieve(
request: Request, req: Request,
wallet: WalletTypeInfo = Depends(get_key_type), wallet: WalletTypeInfo = Depends(get_key_type),
lnurldevice_id: str = Query(None), lnurldevice_id: str = Query(None),
): ):
@@ -68,7 +78,7 @@ async def api_lnurldevice_retrieve(
) )
if not lnurldevice.lnurl_toggle: if not lnurldevice.lnurl_toggle:
return {**lnurldevice.dict()} return {**lnurldevice.dict()}
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(request=request)}} return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}}
@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}") @lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")