diff --git a/.env.example b/.env.example index 93b82325..987c6ca6 100644 --- a/.env.example +++ b/.env.example @@ -37,11 +37,11 @@ LNBITS_RESERVE_FEE_PERCENT=1.0 LNBITS_SITE_TITLE="LNbits" LNBITS_SITE_TAGLINE="free and open-source lightning wallet" LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'" -# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic -LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador" +# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic +LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador" # LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg" -# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet +# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet, LnTipsWallet # LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, @@ -91,4 +91,9 @@ LNBITS_DENOMINATION=sats # EclairWallet ECLAIR_URL=http://127.0.0.1:8283 -ECLAIR_PASS=eclairpw \ No newline at end of file +ECLAIR_PASS=eclairpw + +# LnTipsWallet +# Enter /api in LightningTipBot to get your key +LNTIPS_API_KEY=LNTIPS_ADMIN_KEY +LNTIPS_API_ENDPOINT=https://ln.tips diff --git a/docs/devs/websockets.md b/docs/devs/websockets.md new file mode 100644 index 00000000..0638e4f2 --- /dev/null +++ b/docs/devs/websockets.md @@ -0,0 +1,87 @@ +--- +layout: default +parent: For developers +title: Websockets +nav_order: 2 +--- + + +Websockets +================= + +`websockets` are a great way to add a two way instant data channel between server and client. This example was taken from the `copilot` extension, we create a websocket endpoint which can be restricted by `id`, then can feed it data to broadcast to any client on the socket using the `updater(extension_id, data)` function (`extension` has been used in place of an extension name, wreplace to your own extension): + + +```sh +from fastapi import Request, WebSocket, WebSocketDisconnect + +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket, extension_id: str): + await websocket.accept() + websocket.id = extension_id + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def send_personal_message(self, message: str, extension_id: str): + for connection in self.active_connections: + if connection.id == extension_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() + + +@extension_ext.websocket("/ws/{extension_id}", name="extension.websocket_by_id") +async def websocket_endpoint(websocket: WebSocket, extension_id: str): + await manager.connect(websocket, extension_id) + try: + while True: + data = await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(websocket) + + +async def updater(extension_id, data): + extension = await get_extension(extension_id) + if not extension: + return + await manager.send_personal_message(f"{data}", extension_id) +``` + +Example vue-js function for listening to the websocket: + +``` +initWs: async function () { + if (location.protocol !== 'http:') { + localUrl = + 'wss://' + + document.domain + + ':' + + location.port + + '/extension/ws/' + + self.extension.id + } else { + localUrl = + 'ws://' + + document.domain + + ':' + + location.port + + '/extension/ws/' + + self.extension.id + } + this.ws = new WebSocket(localUrl) + this.ws.addEventListener('message', async ({data}) => { + const res = JSON.parse(data.toString()) + console.log(res) + }) +}, +``` diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 87679ed5..6b95f93b 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -18,21 +18,25 @@ If you have problems installing LNbits using these instructions, please have a l git clone https://github.com/lnbits/lnbits-legend.git cd lnbits-legend/ -# for making sure python 3.9 is installed, skip if installed +# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version sudo apt update sudo apt install software-properties-common sudo add-apt-repository ppa:deadsnakes/ppa sudo apt install python3.9 python3.9-distutils curl -sSL https://install.python-poetry.org | python3 - -export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal +# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command +export PATH="/home/user/.local/bin:$PATH" +# Next command, you can exchange with python3.10 or newer versions. +# Identify your version with python3 --version and specify in the next line +# command is only needed when your default python is not ^3.9 or ^3.10 poetry env use python3.9 -poetry install --no-dev -poetry run python build.py +poetry install --only main mkdir data cp .env.example .env -nano .env # set funding source +# set funding source amongst other options +nano .env ``` #### Running the server @@ -40,6 +44,8 @@ nano .env # set funding source ```sh poetry run lnbits # To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0' +# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output +# Note that you have to add the line DEBUG=true in your .env file, too. ``` ## Option 2: Nix diff --git a/lnbits/app.py b/lnbits/app.py index 51482538..8b9cf798 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -126,7 +126,7 @@ def check_funding_source(app: FastAPI) -> None: logger.info("Retrying connection to backend in 5 seconds...") await asyncio.sleep(5) signal.signal(signal.SIGINT, original_sigint_handler) - logger.info( + logger.success( f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." ) diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index cbed6292..bb1ca0c1 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -333,7 +333,7 @@ async def delete_expired_invoices( """ ) logger.debug(f"Checking expiry of {len(rows)} invoices") - for (payment_request,) in rows: + for i, (payment_request,) in enumerate(rows): try: invoice = bolt11.decode(payment_request) except: @@ -343,7 +343,7 @@ async def delete_expired_invoices( if expiration_date > datetime.datetime.utcnow(): continue logger.debug( - f"Deleting expired invoice: {invoice.payment_hash} (expired: {expiration_date})" + f"Deleting expired invoice {i}/{len(rows)}: {invoice.payment_hash} (expired: {expiration_date})" ) await (conn or db).execute( """ diff --git a/lnbits/core/templates/core/index.html b/lnbits/core/templates/core/index.html index f769b44f..68a7b7ed 100644 --- a/lnbits/core/templates/core/index.html +++ b/lnbits/core/templates/core/index.html @@ -171,6 +171,17 @@ +
+
+ + + +
+
 
+
diff --git a/lnbits/db.py b/lnbits/db.py index 66981784..f52b0391 100644 --- a/lnbits/db.py +++ b/lnbits/db.py @@ -52,6 +52,12 @@ class Compat: return "" return "" + @property + def big_int(self) -> str: + if self.type in {POSTGRES}: + return "BIGINT" + return "INT" + class Connection(Compat): def __init__(self, conn: AsyncConnection, txn, typ, name, schema): diff --git a/lnbits/extensions/boltcards/migrations.py b/lnbits/extensions/boltcards/migrations.py index 08126013..9609e0c3 100644 --- a/lnbits/extensions/boltcards/migrations.py +++ b/lnbits/extensions/boltcards/migrations.py @@ -29,7 +29,7 @@ async def m001_initial(db): ) await db.execute( - """ + f""" CREATE TABLE boltcards.hits ( id TEXT PRIMARY KEY UNIQUE, card_id TEXT NOT NULL, @@ -38,7 +38,7 @@ async def m001_initial(db): useragent TEXT, old_ctr INT NOT NULL DEFAULT 0, new_ctr INT NOT NULL DEFAULT 0, - amount INT NOT NULL, + amount {db.big_int} NOT NULL, time TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """ @@ -47,11 +47,11 @@ async def m001_initial(db): ) await db.execute( - """ + f""" CREATE TABLE boltcards.refunds ( id TEXT PRIMARY KEY UNIQUE, hit_id TEXT NOT NULL, - refund_amount INT NOT NULL, + refund_amount {db.big_int} NOT NULL, time TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """ diff --git a/lnbits/extensions/boltz/migrations.py b/lnbits/extensions/boltz/migrations.py index e4026dd0..925322ec 100644 --- a/lnbits/extensions/boltz/migrations.py +++ b/lnbits/extensions/boltz/migrations.py @@ -1,16 +1,16 @@ async def m001_initial(db): await db.execute( - """ + f""" CREATE TABLE boltz.submarineswap ( id TEXT PRIMARY KEY, wallet TEXT NOT NULL, payment_hash TEXT NOT NULL, - amount INT NOT NULL, + amount {db.big_int} NOT NULL, status TEXT NOT NULL, boltz_id TEXT NOT NULL, refund_address TEXT NOT NULL, refund_privkey TEXT NOT NULL, - expected_amount INT NOT NULL, + expected_amount {db.big_int} NOT NULL, timeout_block_height INT NOT NULL, address TEXT NOT NULL, bip21 TEXT NOT NULL, @@ -22,12 +22,12 @@ async def m001_initial(db): """ ) await db.execute( - """ + f""" CREATE TABLE boltz.reverse_submarineswap ( id TEXT PRIMARY KEY, wallet TEXT NOT NULL, onchain_address TEXT NOT NULL, - amount INT NOT NULL, + amount {db.big_int} NOT NULL, instant_settlement BOOLEAN NOT NULL, status TEXT NOT NULL, boltz_id TEXT NOT NULL, @@ -37,7 +37,7 @@ async def m001_initial(db): claim_privkey TEXT NOT NULL, lockup_address TEXT NOT NULL, invoice TEXT NOT NULL, - onchain_amount INT NOT NULL, + onchain_amount {db.big_int} NOT NULL, time TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """ diff --git a/lnbits/extensions/invoices/migrations.py b/lnbits/extensions/invoices/migrations.py index c47a954a..74a0fdba 100644 --- a/lnbits/extensions/invoices/migrations.py +++ b/lnbits/extensions/invoices/migrations.py @@ -45,7 +45,7 @@ async def m001_initial_invoices(db): id TEXT PRIMARY KEY, invoice_id TEXT NOT NULL, - amount INT NOT NULL, + amount {db.big_int} NOT NULL, time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, diff --git a/lnbits/extensions/lnurldevice/__init__.py b/lnbits/extensions/lnurldevice/__init__.py index 54849c95..d2010c44 100644 --- a/lnbits/extensions/lnurldevice/__init__.py +++ b/lnbits/extensions/lnurldevice/__init__.py @@ -1,7 +1,10 @@ +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_lnurldevice") @@ -13,5 +16,11 @@ def lnurldevice_renderer(): from .lnurl import * # noqa +from .tasks import wait_for_paid_invoices from .views 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)) diff --git a/lnbits/extensions/lnurldevice/crud.py b/lnbits/extensions/lnurldevice/crud.py index 45166521..4c25e4cb 100644 --- a/lnbits/extensions/lnurldevice/crud.py +++ b/lnbits/extensions/lnurldevice/crud.py @@ -22,9 +22,10 @@ async def create_lnurldevice( wallet, currency, device, - profit + profit, + amount ) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( lnurldevice_id, @@ -34,6 +35,7 @@ async def create_lnurldevice( data.currency, data.device, data.profit, + data.amount, ), ) return await get_lnurldevice(lnurldevice_id) diff --git a/lnbits/extensions/lnurldevice/lnurl.py b/lnbits/extensions/lnurldevice/lnurl.py index df0cd4b8..79892b78 100644 --- a/lnbits/extensions/lnurldevice/lnurl.py +++ b/lnbits/extensions/lnurldevice/lnurl.py @@ -102,7 +102,32 @@ async def lnurl_v1_params( if device.device == "atm": if paymentcheck: 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: p += "=" * (4 - (len(p) % 4)) @@ -184,22 +209,42 @@ async def lnurl_callback( raise HTTPException( status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found." ) - if pr: - if lnurldevicepayment.id != k1: - return {"status": "ERROR", "reason": "Bad K1"} - if lnurldevicepayment.payhash != "payment_hash": - return {"status": "ERROR", "reason": f"Payment already claimed"} + if device.device == "atm": + if not pr: + raise HTTPException( + status_code=HTTPStatus.FORBIDDEN, detail="No payment request" + ) + 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_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, - payment_request=pr, - max_sat=lnurldevicepayment.sats / 1000, - extra={"tag": "withdraw"}, + amount=lnurldevicepayment.sats / 1000, + memo=device.title + "-" + lnurldevicepayment.id, + 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( wallet_id=device.wallet, @@ -221,5 +266,3 @@ async def lnurl_callback( }, "routes": [], } - - return resp.dict() diff --git a/lnbits/extensions/lnurldevice/migrations.py b/lnbits/extensions/lnurldevice/migrations.py index c7899282..7305cceb 100644 --- a/lnbits/extensions/lnurldevice/migrations.py +++ b/lnbits/extensions/lnurldevice/migrations.py @@ -29,7 +29,7 @@ async def m001_initial(db): payhash TEXT, payload TEXT NOT NULL, pin INT, - sats INT, + sats {db.big_int}, timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); """ @@ -79,3 +79,12 @@ async def m002_redux(db): ) except: 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;" + ) diff --git a/lnbits/extensions/lnurldevice/models.py b/lnbits/extensions/lnurldevice/models.py index fef0aec1..01bcc2ba 100644 --- a/lnbits/extensions/lnurldevice/models.py +++ b/lnbits/extensions/lnurldevice/models.py @@ -17,6 +17,7 @@ class createLnurldevice(BaseModel): currency: str device: str profit: float + amount: int class lnurldevices(BaseModel): @@ -27,15 +28,14 @@ class lnurldevices(BaseModel): currency: str device: str profit: float + amount: int timestamp: str def from_row(cls, row: Row) -> "lnurldevices": return cls(**dict(row)) def lnurl(self, req: Request) -> Lnurl: - url = req.url_for( - "lnurldevice.lnurl_response", device_id=self.id, _external=True - ) + url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id) return lnurl_encode(url) async def lnurlpay_metadata(self) -> LnurlPayMetadata: diff --git a/lnbits/extensions/lnurldevice/tasks.py b/lnbits/extensions/lnurldevice/tasks.py new file mode 100644 index 00000000..c8f3db04 --- /dev/null +++ b/lnbits/extensions/lnurldevice/tasks.py @@ -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 diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html index 7f9afa27..f93d44d8 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/_api_docs.html @@ -1,13 +1,24 @@

- Register LNURLDevice devices to receive payments in your LNbits wallet.
- Build your own here - https://github.com/arcbtc/bitcoinpos + Use with:
+ LNPoS + + https://lnbits.github.io/lnpos
+ bitcoinSwitch + + https://github.com/lnbits/bitcoinSwitch
+ FOSSA + + https://github.com/lnbits/fossa
- Created by, Ben ArcBen Arc, + BC, + Vlad Stan

diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html index 24d19484..028dd94b 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html @@ -51,6 +51,7 @@ + LNURLDevice Settings + + + LNURLs only work over HTTPS view LNURL +
LNURLDevice device string
- {% raw %}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{% + endraw %} Click to copy URL + + {% raw - %}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}}, - {{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw - %} Click to copy URL - - + >{% raw + %}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}}, + {{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw + %} Click to copy URL + +
@@ -191,6 +221,7 @@ label="Type of device" > +
+ + +
+ + + + + + {% raw %} + +

+ ID: {{ qrCodeDialog.data.id }}
+

+ {% endraw %} +
+ Copy LNURL + Close +
+
+
{% endblock %} {% block scripts %} {{ window_vars(user) }} @@ -252,7 +333,9 @@ mixins: [windowMixin], data: function () { return { + protocol: window.location.protocol, location: window.location.hostname, + wslocation: window.location.hostname, filter: '', currency: 'USD', lnurldeviceLinks: [], @@ -265,6 +348,10 @@ { label: 'ATM', value: 'atm' + }, + { + label: 'Switch', + value: 'switch' } ], lnurldevicesTable: { @@ -333,7 +420,8 @@ show_ack: false, show_price: 'None', device: 'pos', - profit: 2, + profit: 0, + amount: 1, title: '' } }, @@ -344,6 +432,16 @@ } }, 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) { var self = this self.formDialoglnurldevice.show = false @@ -400,6 +498,7 @@ .then(function (response) { if (response.data) { self.lnurldeviceLinks = response.data.map(maplnurldevice) + console.log(response.data) } }) .catch(function (error) { @@ -519,6 +618,7 @@ '//', window.location.host ].join('') + self.wslocation = ['ws://', window.location.host].join('') LNbits.api .request('GET', '/api/v1/currencies') .then(response => { diff --git a/lnbits/extensions/lnurldevice/views.py b/lnbits/extensions/lnurldevice/views.py index 3389e17c..5c6eba24 100644 --- a/lnbits/extensions/lnurldevice/views.py +++ b/lnbits/extensions/lnurldevice/views.py @@ -1,11 +1,13 @@ 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.params import Depends from fastapi.templating import Jinja2Templates 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.models import User @@ -51,3 +53,58 @@ async def displaypin(request: Request, paymentid: str = Query(None)): "lnurldevice/error.html", {"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) diff --git a/lnbits/extensions/lnurldevice/views_api.py b/lnbits/extensions/lnurldevice/views_api.py index d152d210..c034f66e 100644 --- a/lnbits/extensions/lnurldevice/views_api.py +++ b/lnbits/extensions/lnurldevice/views_api.py @@ -32,32 +32,42 @@ async def api_list_currencies_available(): @lnurldevice_ext.post("/api/v1/lnurlpos") @lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}") async def api_lnurldevice_create_or_update( + req: Request, data: createLnurldevice, wallet: WalletTypeInfo = Depends(require_admin_key), lnurldevice_id: str = Query(None), ): if not lnurldevice_id: lnurldevice = await create_lnurldevice(data) - return lnurldevice.dict() + return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(req)}} else: 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") -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 try: 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: - return "" + try: + return [ + {**lnurldevice.dict()} + for lnurldevice in await get_lnurldevices(wallet_ids) + ] + except: + return "" @lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}") async def api_lnurldevice_retrieve( - request: Request, + req: Request, wallet: WalletTypeInfo = Depends(get_key_type), lnurldevice_id: str = Query(None), ): @@ -68,7 +78,7 @@ async def api_lnurldevice_retrieve( ) if not lnurldevice.lnurl_toggle: 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}") diff --git a/lnbits/extensions/lnurlpayout/migrations.py b/lnbits/extensions/lnurlpayout/migrations.py index 6af04791..7a45e495 100644 --- a/lnbits/extensions/lnurlpayout/migrations.py +++ b/lnbits/extensions/lnurlpayout/migrations.py @@ -3,14 +3,14 @@ async def m001_initial(db): Initial lnurlpayouts table. """ await db.execute( - """ + f""" 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 + threshold {db.big_int} NOT NULL ); """ ) diff --git a/lnbits/extensions/scrub/README.md b/lnbits/extensions/scrub/README.md index 680c5e6d..3b8d0b2d 100644 --- a/lnbits/extensions/scrub/README.md +++ b/lnbits/extensions/scrub/README.md @@ -4,6 +4,8 @@ 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! +Only whole values, integers, are Scrubbed, amounts will be rounded down (example: 6.3 will be 6)! The decimals, if existing, will be kept in your wallet! + [**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets) ## Usage diff --git a/lnbits/extensions/scrub/tasks.py b/lnbits/extensions/scrub/tasks.py index 320d34da..852f3860 100644 --- a/lnbits/extensions/scrub/tasks.py +++ b/lnbits/extensions/scrub/tasks.py @@ -1,6 +1,7 @@ import asyncio import json from http import HTTPStatus +from math import floor from urllib.parse import urlparse import httpx @@ -26,7 +27,7 @@ async def wait_for_paid_invoices(): async def on_invoice_paid(payment: Payment) -> None: # (avoid loops) - if "scrubed" == payment.extra.get("tag"): + if payment.extra.get("tag") == "scrubed": # already scrubbed return @@ -42,12 +43,13 @@ async def on_invoice_paid(payment: Payment) -> None: # I REALLY HATE THIS DUPLICATION OF CODE!! CORE/VIEWS/API.PY, LINE 267 domain = urlparse(data["callback"]).netloc + rounded_amount = floor(payment.amount / 1000) * 1000 async with httpx.AsyncClient() as client: try: r = await client.get( data["callback"], - params={"amount": payment.amount}, + params={"amount": rounded_amount}, timeout=40, ) if r.is_error: @@ -66,7 +68,8 @@ async def on_invoice_paid(payment: Payment) -> None: ) invoice = bolt11.decode(params["pr"]) - if invoice.amount_msat != payment.amount: + + if invoice.amount_msat != rounded_amount: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail=f"{domain} returned an invalid invoice. Expected {payment.amount} msat, got {invoice.amount_msat}.", diff --git a/lnbits/extensions/splitpayments/tasks.py b/lnbits/extensions/splitpayments/tasks.py index b7cf1750..cfc6c226 100644 --- a/lnbits/extensions/splitpayments/tasks.py +++ b/lnbits/extensions/splitpayments/tasks.py @@ -28,6 +28,10 @@ async def on_invoice_paid(payment: Payment) -> None: # now we make some special internal transfers (from no one to the receiver) targets = await get_targets(payment.wallet_id) + + if not targets: + return + transfers = [ (target.wallet, int(target.percent * payment.amount / 100)) for target in targets @@ -41,9 +45,6 @@ async def on_invoice_paid(payment: Payment) -> None: ) return - if not targets: - return - # mark the original payment with one extra key, "splitted" # (this prevents us from doing this process again and it's informative) # and reduce it by the amount we're going to send to the producer @@ -76,5 +77,5 @@ async def on_invoice_paid(payment: Payment) -> None: ) # manually send this for now - await internal_invoice_queue.put(internal_checking_id) + await internal_invoice_queue.put(internal_checking_id) return diff --git a/lnbits/extensions/streamalerts/migrations.py b/lnbits/extensions/streamalerts/migrations.py index 1b0cea37..7d50e8f1 100644 --- a/lnbits/extensions/streamalerts/migrations.py +++ b/lnbits/extensions/streamalerts/migrations.py @@ -25,7 +25,7 @@ async def m001_initial(db): name TEXT NOT NULL, message TEXT NOT NULL, cur_code TEXT NOT NULL, - sats INT NOT NULL, + sats {db.big_int} NOT NULL, amount FLOAT NOT NULL, service INTEGER NOT NULL, posted BOOLEAN NOT NULL, diff --git a/lnbits/extensions/tipjar/migrations.py b/lnbits/extensions/tipjar/migrations.py index 6b58fbca..d8f6da3f 100644 --- a/lnbits/extensions/tipjar/migrations.py +++ b/lnbits/extensions/tipjar/migrations.py @@ -19,8 +19,8 @@ async def m001_initial(db): wallet TEXT NOT NULL, name TEXT NOT NULL, message TEXT NOT NULL, - sats INT NOT NULL, - tipjar INT NOT NULL, + sats {db.big_int} NOT NULL, + tipjar {db.big_int} NOT NULL, FOREIGN KEY(tipjar) REFERENCES {db.references_schema}TipJars(id) ); """ diff --git a/lnbits/static/images/mynode.png b/lnbits/static/images/mynode.png index cf25bc58..390446b8 100644 Binary files a/lnbits/static/images/mynode.png and b/lnbits/static/images/mynode.png differ diff --git a/lnbits/static/images/mynodel.png b/lnbits/static/images/mynodel.png index b8afb9ff..344b54b6 100644 Binary files a/lnbits/static/images/mynodel.png and b/lnbits/static/images/mynodel.png differ diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index 41949652..fa533566 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa + from .cliche import ClicheWallet from .cln import CoreLightningWallet # legacy .env support from .cln import CoreLightningWallet as CLightningWallet @@ -9,6 +10,7 @@ from .lnbits import LNbitsWallet from .lndgrpc import LndWallet from .lndrest import LndRestWallet from .lnpay import LNPayWallet +from .lntips import LnTipsWallet from .lntxbot import LntxbotWallet from .opennode import OpenNodeWallet from .spark import SparkWallet diff --git a/lnbits/wallets/lntips.py b/lnbits/wallets/lntips.py new file mode 100644 index 00000000..54220c85 --- /dev/null +++ b/lnbits/wallets/lntips.py @@ -0,0 +1,170 @@ +import asyncio +import hashlib +import json +import time +from os import getenv +from typing import AsyncGenerator, Dict, Optional + +import httpx +from loguru import logger + +from .base import ( + InvoiceResponse, + PaymentResponse, + PaymentStatus, + StatusResponse, + Wallet, +) + + +class LnTipsWallet(Wallet): + def __init__(self): + endpoint = getenv("LNTIPS_API_ENDPOINT") + self.endpoint = endpoint[:-1] if endpoint.endswith("/") else endpoint + + key = ( + getenv("LNTIPS_API_KEY") + or getenv("LNTIPS_ADMIN_KEY") + or getenv("LNTIPS_INVOICE_KEY") + ) + self.auth = {"Authorization": f"Basic {key}"} + + async def status(self) -> StatusResponse: + async with httpx.AsyncClient() as client: + r = await client.get( + f"{self.endpoint}/api/v1/balance", headers=self.auth, timeout=40 + ) + try: + data = r.json() + except: + return StatusResponse( + f"Failed to connect to {self.endpoint}, got: '{r.text[:200]}...'", 0 + ) + + if data.get("error"): + return StatusResponse(data["error"], 0) + + return StatusResponse(None, data["balance"] * 1000) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + **kwargs, + ) -> InvoiceResponse: + data: Dict = {"amount": amount} + if description_hash: + data["description_hash"] = description_hash.hex() + elif unhashed_description: + data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest() + else: + data["memo"] = memo or "" + + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/api/v1/createinvoice", + headers=self.auth, + json=data, + timeout=40, + ) + + if r.is_error: + try: + data = r.json() + error_message = data["message"] + except: + error_message = r.text + pass + + return InvoiceResponse(False, None, None, error_message) + + data = r.json() + return InvoiceResponse( + True, data["payment_hash"], data["payment_request"], None + ) + + async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/api/v1/payinvoice", + headers=self.auth, + json={"pay_req": bolt11}, + timeout=None, + ) + if r.is_error: + return PaymentResponse(False, None, 0, None, r.text) + + if "error" in r.json(): + try: + data = r.json() + error_message = data["error"] + except: + error_message = r.text + pass + return PaymentResponse(False, None, 0, None, error_message) + + data = r.json()["details"] + checking_id = data["payment_hash"] + fee_msat = -data["fee"] + preimage = data["preimage"] + return PaymentResponse(True, checking_id, fee_msat, preimage, None) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient() as client: + r = await client.post( + f"{self.endpoint}/api/v1/invoicestatus/{checking_id}", + headers=self.auth, + ) + + if r.is_error or len(r.text) == 0: + return PaymentStatus(None) + + data = r.json() + return PaymentStatus(data["paid"]) + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + async with httpx.AsyncClient() as client: + r = await client.post( + url=f"{self.endpoint}/api/v1/paymentstatus/{checking_id}", + headers=self.auth, + ) + + if r.is_error: + return PaymentStatus(None) + data = r.json() + + paid_to_status = {False: None, True: True} + return PaymentStatus(paid_to_status[data.get("paid")]) + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + last_connected = None + while True: + url = f"{self.endpoint}/api/v1/invoicestream" + try: + async with httpx.AsyncClient(timeout=None, headers=self.auth) as client: + last_connected = time.time() + async with client.stream("GET", url) as r: + async for line in r.aiter_lines(): + try: + prefix = "data: " + if not line.startswith(prefix): + continue + data = line[len(prefix) :] # sse parsing + inv = json.loads(data) + if not inv.get("payment_hash"): + continue + except: + continue + yield inv["payment_hash"] + except Exception as e: + pass + + # do not sleep if the connection was active for more than 10s + # since the backend is expected to drop the connection after 90s + if last_connected is None or time.time() - last_connected < 10: + logger.error( + f"lost connection to {self.endpoint}/api/v1/invoicestream, retrying in 5 seconds" + ) + await asyncio.sleep(5)