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)